diff --git a/.bingo/go-xgettext.mod b/.bingo/go-xgettext.mod index b14946a0b8..8e3c6210b5 100644 --- a/.bingo/go-xgettext.mod +++ b/.bingo/go-xgettext.mod @@ -3,5 +3,3 @@ module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT go 1.23.4 require github.com/gosexy/gettext v0.0.0-20160830220431-74466a0a0c4a // go-xgettext - -require github.com/jessevdk/go-flags v1.6.1 // indirect diff --git a/services/collaboration/mocks/file_connector_service.go b/services/collaboration/mocks/file_connector_service.go index 3e9f5041db..7baf292d92 100644 --- a/services/collaboration/mocks/file_connector_service.go +++ b/services/collaboration/mocks/file_connector_service.go @@ -169,6 +169,74 @@ func (_c *FileConnectorService_DeleteFile_Call) RunAndReturn(run func(ctx contex return _c } +// GetAvatar provides a mock function for the type FileConnectorService +func (_mock *FileConnectorService) GetAvatar(ctx context.Context, userID string) (*connector.ConnectorResponse, error) { + ret := _mock.Called(ctx, userID) + + if len(ret) == 0 { + panic("no return value specified for GetAvatar") + } + + var r0 *connector.ConnectorResponse + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*connector.ConnectorResponse, error)); ok { + return returnFunc(ctx, userID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *connector.ConnectorResponse); ok { + r0 = returnFunc(ctx, userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*connector.ConnectorResponse) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, userID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// FileConnectorService_GetAvatar_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAvatar' +type FileConnectorService_GetAvatar_Call struct { + *mock.Call +} + +// GetAvatar is a helper method to define mock.On call +// - ctx context.Context +// - userID string +func (_e *FileConnectorService_Expecter) GetAvatar(ctx interface{}, userID interface{}) *FileConnectorService_GetAvatar_Call { + return &FileConnectorService_GetAvatar_Call{Call: _e.mock.On("GetAvatar", ctx, userID)} +} + +func (_c *FileConnectorService_GetAvatar_Call) Run(run func(ctx context.Context, userID string)) *FileConnectorService_GetAvatar_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *FileConnectorService_GetAvatar_Call) Return(connectorResponse *connector.ConnectorResponse, err error) *FileConnectorService_GetAvatar_Call { + _c.Call.Return(connectorResponse, err) + return _c +} + +func (_c *FileConnectorService_GetAvatar_Call) RunAndReturn(run func(ctx context.Context, userID string) (*connector.ConnectorResponse, error)) *FileConnectorService_GetAvatar_Call { + _c.Call.Return(run) + return _c +} + // GetLock provides a mock function for the type FileConnectorService func (_mock *FileConnectorService) GetLock(ctx context.Context) (*connector.ConnectorResponse, error) { ret := _mock.Called(ctx) diff --git a/services/collaboration/pkg/command/server.go b/services/collaboration/pkg/command/server.go index 92e14c0972..afbd1ebf7f 100644 --- a/services/collaboration/pkg/command/server.go +++ b/services/collaboration/pkg/command/server.go @@ -23,6 +23,7 @@ import ( "github.com/opencloud-eu/reva/v2/pkg/store" "github.com/spf13/cobra" + "go-micro.dev/v4/selector" microstore "go-micro.dev/v4/store" ) @@ -138,7 +139,7 @@ func Server(cfg *config.Config) *cobra.Command { // start HTTP server httpServer, err := http.Server( - http.Adapter(connector.NewHttpAdapter(gatewaySelector, cfg, st)), + http.Adapter(connector.NewHttpAdapter(gatewaySelector, cfg, st, selector.NewSelector(selector.Registry(registry.GetRegistry())))), http.Logger(logger), http.Config(cfg), http.Context(ctx), diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index 5a25479ab2..26de853006 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -8,11 +8,15 @@ import ( "encoding/hex" "fmt" "io" + "net/http" "net/url" "path" "strings" "time" + libregraph "github.com/opencloud-eu/libre-graph-api-go" + "go-micro.dev/v4/selector" + appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" auth "github.com/cs3org/go-cs3apis/cs3/auth/provider/v1beta1" gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" @@ -94,23 +98,29 @@ type FileConnectorService interface { // In case of conflict, this method will return the actual lockId in // the file as second return value. RenameFile(ctx context.Context, lockID, target string) (*ConnectorResponse, error) + // GetAvatar fetches the user's avatar image from the Graph API. + // The response Body contains the raw image bytes ([]byte) and the + // Headers contain Content-Type and Cache-Control. + GetAvatar(ctx context.Context, userID string) (*ConnectorResponse, error) } // FileConnector implements the "File" endpoint. // Currently, it handles file locks and getting the file info. // Note that operations might return any kind of error, not just ConnectorError type FileConnector struct { - gws pool.Selectable[gatewayv1beta1.GatewayAPIClient] - cfg *config.Config - store microstore.Store + gws pool.Selectable[gatewayv1beta1.GatewayAPIClient] + cfg *config.Config + store microstore.Store + graphSelector selector.Selector } // NewFileConnector creates a new file connector -func NewFileConnector(gws pool.Selectable[gatewayv1beta1.GatewayAPIClient], cfg *config.Config, st microstore.Store) *FileConnector { +func NewFileConnector(gws pool.Selectable[gatewayv1beta1.GatewayAPIClient], cfg *config.Config, st microstore.Store, graphSelector selector.Selector) *FileConnector { return &FileConnector{ - gws: gws, - cfg: cfg, - store: st, + gws: gws, + cfg: cfg, + store: st, + graphSelector: graphSelector, } } @@ -1303,6 +1313,18 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (*ConnectorResponse, } } + if !isPublicShare && !isAnonymousUser { + extraInfo := &fileinfo.UserExtraInfo{ + Mail: user.GetMail(), + } + // Build a WOPI-proxied avatar URL so Collabora can load it via img.src + // without needing auth headers (the token is in the query string). + if avatarURL, err := f.createAvatarURL(wopiContext, collaborationURL, user.GetId().GetOpaqueId()); err == nil { + extraInfo.Avatar = avatarURL + } + infoMap[fileinfo.KeyUserExtraInfo] = extraInfo + } + // if the file content is empty and a template reference is set, add the template source URL if wopiContext.TemplateReference != nil && statRes.GetInfo().GetSize() == 0 { if tu, err := f.createDownloadURL(wopiContext, collaborationURL); err == nil { @@ -1340,6 +1362,22 @@ func (f *FileConnector) createDownloadURL(wopiContext middleware.WopiContext, co return downloadURL.String(), nil } +// createAvatarURL builds a WOPI-proxied avatar URL for the given user. +// The collaboration service's /wopi/avatars/ endpoint will fetch the avatar +// from the Graph API using the WOPI token for authentication. +func (f *FileConnector) createAvatarURL(wopiContext middleware.WopiContext, collaborationURL *url.URL, userID string) (string, error) { + token, _, err := middleware.GenerateWopiToken(wopiContext, f.cfg, f.store) + if err != nil { + return "", err + } + avatarURL := *collaborationURL + avatarURL.Path = path.Join(collaborationURL.Path, "wopi/avatars/", userID) + q := avatarURL.Query() + q.Add("access_token", token) + avatarURL.RawQuery = q.Encode() + return avatarURL.String(), nil +} + func createHostUrl(mode string, u *url.URL, appName string, info *providerv1beta1.ResourceInfo) string { webUrl := createAppExternalURL(u, appName, info) addURLParams(webUrl, map[string]string{"view_mode": mode}) @@ -1512,3 +1550,70 @@ func (f *FileConnector) getScopeByKeyPrefix(scopes map[string]*auth.Scope, keyPr } return fmt.Errorf("scope %s not found", keyPrefix) } + +// GetAvatar fetches the user's avatar image from the Graph API. +// The returned ConnectorResponse carries the raw image bytes in Body ([]byte) +// and Content-Type / Cache-Control in Headers. +func (f *FileConnector) GetAvatar(ctx context.Context, userID string) (*ConnectorResponse, error) { + logger := zerolog.Ctx(ctx) + + wopiContext, err := middleware.WopiContextFromCtx(ctx) + if err != nil { + logger.Error().Err(err).Msg("GetAvatar: missing WOPI context") + return nil, NewConnectorError(http.StatusUnauthorized, "missing WOPI context") + } + + lgClient, err := f.setupLibregraphClient(ctx, wopiContext.AccessToken) + if err != nil { + logger.Error().Err(err).Msg("GetAvatar: failed to setup libregraph client") + return nil, NewConnectorError(http.StatusInternalServerError, "failed to setup libregraph client") + } + + photoFile, httpResp, err := lgClient.UserPhotoApi.GetUserPhoto(ctx, userID).Execute() + if err != nil { + logger.Error().Err(err).Msg("GetAvatar: failed to fetch avatar from Graph API") + if httpResp != nil { + return nil, NewConnectorError(httpResp.StatusCode, http.StatusText(httpResp.StatusCode)) + } + return nil, NewConnectorError(http.StatusBadGateway, "failed to fetch avatar") + } + defer photoFile.Close() + + data, err := io.ReadAll(photoFile) + if err != nil { + logger.Error().Err(err).Msg("GetAvatar: failed to read avatar body") + return nil, NewConnectorError(http.StatusInternalServerError, "failed to read avatar body") + } + + headers := map[string]string{ + "Cache-Control": "public, max-age=300", + } + if ct := httpResp.Header.Get("Content-Type"); ct != "" { + headers["Content-Type"] = ct + } + + return &ConnectorResponse{ + Status: http.StatusOK, + Headers: headers, + Body: data, + }, nil +} + +func (f *FileConnector) setupLibregraphClient(_ context.Context, cs3token string) (*libregraph.APIClient, error) { + next, err := f.graphSelector.Select("eu.opencloud.web.graph") + if err != nil { + return nil, err + } + node, err := next() + if err != nil { + return nil, err + } + lgconf := libregraph.NewConfiguration() + lgconf.Servers = libregraph.ServerConfigurations{ + { + URL: fmt.Sprintf("%s://%s/graph", node.Metadata["protocol"], node.Address), + }, + } + lgconf.DefaultHeader = map[string]string{ctxpkg.TokenHeader: cs3token} + return libregraph.NewAPIClient(lgconf), nil +} diff --git a/services/collaboration/pkg/connector/fileconnector_test.go b/services/collaboration/pkg/connector/fileconnector_test.go index 95126cde81..bd1dfdda98 100644 --- a/services/collaboration/pkg/connector/fileconnector_test.go +++ b/services/collaboration/pkg/connector/fileconnector_test.go @@ -65,7 +65,7 @@ var _ = Describe("FileConnector", func() { gatewaySelector = mocks.NewSelectable[gateway.GatewayAPIClient](GinkgoT()) gatewaySelector.On("Next").Return(gatewayClient, nil) - fc = connector.NewFileConnector(gatewaySelector, cfg, nil) + fc = connector.NewFileConnector(gatewaySelector, cfg, nil, nil) wopiCtx = middleware.WopiContext{ // a real token is needed for the PutRelativeFileSuggested tests @@ -1996,13 +1996,24 @@ var _ = Describe("FileConnector", func() { EnableInsertRemoteImage: true, EnableInsertRemoteFile: true, IsAdminUser: true, + UserExtraInfo: &fileinfo.UserExtraInfo{ + Mail: "shaft@example.com", + }, } response, err := fc.CheckFileInfo(ctx) Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) - Expect(response.Body.(*fileinfo.Collabora)).To(Equal(expectedFileInfo)) + body := response.Body.(*fileinfo.Collabora) + // Avatar URL contains a dynamic WOPI token, so check prefix separately + Expect(body.UserExtraInfo).ToNot(BeNil()) + Expect(body.UserExtraInfo.Avatar).To(ContainSubstring("/wopi/avatars/aabbcc?access_token=")) + Expect(body.UserExtraInfo.Mail).To(Equal("shaft@example.com")) + // Clear dynamic fields for struct comparison + body.UserExtraInfo.Avatar = "" + expectedFileInfo.UserExtraInfo.Avatar = "" + Expect(body).To(Equal(expectedFileInfo)) }) It("Stat success with template", func() { wopiCtx.TemplateReference = &providerv1beta1.Reference{ @@ -2097,4 +2108,18 @@ var _ = Describe("FileConnector", func() { Expect(templateSource).To(HavePrefix(expectedTemplateSource)) }) }) + + Describe("GetAvatar", func() { + It("No valid context returns Unauthorized error", func() { + // GetAvatar returns before touching the gateway selector. + gatewaySelector.EXPECT().Next().Unset() + ctx := context.Background() + response, err := fc.GetAvatar(ctx, "user-123") + Expect(response).To(BeNil()) + Expect(err).To(HaveOccurred()) + var connErr *connector.ConnectorError + Expect(errors.As(err, &connErr)).To(BeTrue()) + Expect(connErr.HttpCodeOut).To(Equal(401)) + }) + }) }) diff --git a/services/collaboration/pkg/connector/fileinfo/collabora.go b/services/collaboration/pkg/connector/fileinfo/collabora.go index f5612f457b..802b762bda 100644 --- a/services/collaboration/pkg/connector/fileinfo/collabora.go +++ b/services/collaboration/pkg/connector/fileinfo/collabora.go @@ -1,5 +1,13 @@ package fileinfo +// UserExtraInfo contains additional user info shared across collaborative +// editing views, such as the user's avatar image and email. +// https://sdk.collaboraonline.com/docs/advanced_integration.html#userextrainfo +type UserExtraInfo struct { + Avatar string `json:"avatar,omitempty"` + Mail string `json:"mail,omitempty"` +} + // Collabora fileInfo properties // // Collabora WOPI check file info specification: @@ -64,7 +72,8 @@ type Collabora struct { IsAnonymousUser bool `json:"IsAnonymousUser,omitempty"` // JSON object that contains additional info about the user, namely the avatar image. - //UserExtraInfo -> requires definition, currently not used + // Shared among all views in collaborative editing sessions. + UserExtraInfo *UserExtraInfo `json:"UserExtraInfo,omitempty"` // JSON object that contains additional info about the user, but unlike the UserExtraInfo it is not shared among the views in collaborative editing sessions. //UserPrivateInfo -> requires definition, currently not used @@ -135,7 +144,8 @@ func (cinfo *Collabora) SetProperties(props map[string]interface{}) { cinfo.SaveAsPostmessage = value.(bool) case KeyEnableOwnerTermination: cinfo.EnableOwnerTermination = value.(bool) - //UserExtraInfo -> requires definition, currently not used + case KeyUserExtraInfo: + cinfo.UserExtraInfo = value.(*UserExtraInfo) //UserPrivateInfo -> requires definition, currently not used case KeyWatermarkText: cinfo.WatermarkText = value.(string) diff --git a/services/collaboration/pkg/connector/fileinfo/fileinfo.go b/services/collaboration/pkg/connector/fileinfo/fileinfo.go index 8b2ab16143..84e4815d58 100644 --- a/services/collaboration/pkg/connector/fileinfo/fileinfo.go +++ b/services/collaboration/pkg/connector/fileinfo/fileinfo.go @@ -114,7 +114,7 @@ const ( KeyDownloadAsPostMessage = "DownloadAsPostMessage" KeySaveAsPostmessage = "SaveAsPostmessage" KeyEnableOwnerTermination = "EnableOwnerTermination" - //KeyUserExtraInfo -> requires definition, currently not used + KeyUserExtraInfo = "UserExtraInfo" //KeyUserPrivateInfo -> requires definition, currently not used KeyWatermarkText = "WatermarkText" diff --git a/services/collaboration/pkg/connector/httpadapter.go b/services/collaboration/pkg/connector/httpadapter.go index 3ee48fac92..a983c2b6d9 100644 --- a/services/collaboration/pkg/connector/httpadapter.go +++ b/services/collaboration/pkg/connector/httpadapter.go @@ -6,12 +6,15 @@ import ( "net/http" "strconv" + "github.com/go-chi/chi/v5" + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" "github.com/opencloud-eu/opencloud/services/collaboration/pkg/config" "github.com/opencloud-eu/opencloud/services/collaboration/pkg/connector/utf7" "github.com/opencloud-eu/opencloud/services/collaboration/pkg/locks" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" "github.com/rs/zerolog" + "go-micro.dev/v4/selector" microstore "go-micro.dev/v4/store" ) @@ -45,10 +48,10 @@ type HttpAdapter struct { // NewHttpAdapter will create a new HTTP adapter. A new connector using the // provided gateway API client and configuration will be used in the adapter -func NewHttpAdapter(gws pool.Selectable[gatewayv1beta1.GatewayAPIClient], cfg *config.Config, st microstore.Store) *HttpAdapter { +func NewHttpAdapter(gws pool.Selectable[gatewayv1beta1.GatewayAPIClient], cfg *config.Config, st microstore.Store, graphSelector selector.Selector) *HttpAdapter { httpAdapter := &HttpAdapter{ con: NewConnector( - NewFileConnector(gws, cfg, st), + NewFileConnector(gws, cfg, st, graphSelector), NewContentConnector(gws, cfg), ), } @@ -301,6 +304,50 @@ func (h *HttpAdapter) RenameFile(w http.ResponseWriter, r *http.Request) { h.writeConnectorResponse(w, r, response) } +// GetAvatar proxies the user's avatar from the Graph API. +// The WOPI token in the query string provides authentication (validated by +// the WopiContextAuthMiddleware). Collabora loads avatars via img.src which +// is a plain browser GET — it cannot send auth headers. +func (h *HttpAdapter) GetAvatar(w http.ResponseWriter, r *http.Request) { + logger := zerolog.Ctx(r.Context()) + userID := chi.URLParam(r, "userID") + if userID == "" { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + fileCon := h.con.GetFileConnector() + response, err := fileCon.GetAvatar(r.Context(), userID) + if err != nil { + var connErr *ConnectorError + if errors.As(err, &connErr) { + http.Error(w, http.StatusText(connErr.HttpCodeOut), connErr.HttpCodeOut) + } else { + logger.Error().Err(err).Msg("GetAvatar: failed to fetch avatar") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + h.writeConnectorAvatarResponse(w, r, response) +} + +func (h *HttpAdapter) writeConnectorAvatarResponse(w http.ResponseWriter, r *http.Request, response *ConnectorResponse) { + data, _ := response.Body.([]byte) + for key, value := range response.Headers { + w.Header().Set(key, value) + } + w.WriteHeader(response.Status) + written, err := w.Write(data) + if err != nil { + logger := zerolog.Ctx(r.Context()) + logger.Error(). + Err(err). + Int("TotalBytes", len(data)). + Int("WrittenBytes", written). + Msg("failed to write avatar contents in the HTTP response") + } +} + func (h *HttpAdapter) writeConnectorResponse(w http.ResponseWriter, r *http.Request, response *ConnectorResponse) { jsonBody := []byte{} if response.Body != nil { diff --git a/services/collaboration/pkg/connector/httpadapter_test.go b/services/collaboration/pkg/connector/httpadapter_test.go index f03dbc9a5b..58dd4bbdb8 100644 --- a/services/collaboration/pkg/connector/httpadapter_test.go +++ b/services/collaboration/pkg/connector/httpadapter_test.go @@ -1,6 +1,7 @@ package connector_test import ( + "context" "encoding/json" "errors" "io" @@ -8,6 +9,7 @@ import ( "strings" typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/go-chi/chi/v5" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/opencloud-eu/opencloud/services/collaboration/mocks" @@ -538,6 +540,72 @@ var _ = Describe("HttpAdapter", func() { }) }) + Describe("GetAvatar", func() { + It("Missing userID returns 400", func() { + // No chi route context means chi.URLParam returns "" + req := httptest.NewRequest("GET", "/wopi/avatars/", nil) + w := httptest.NewRecorder() + + httpAdapter.GetAvatar(w, req) + Expect(w.Result().StatusCode).To(Equal(400)) + }) + + It("ConnectorError propagates the status code", func() { + req := httptest.NewRequest("GET", "/wopi/avatars/user-123", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("userID", "user-123") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + w := httptest.NewRecorder() + + fc.On("GetAvatar", mock.Anything, "user-123").Times(1). + Return(nil, connector.NewConnectorError(502, "Bad Gateway")) + + httpAdapter.GetAvatar(w, req) + Expect(w.Result().StatusCode).To(Equal(502)) + }) + + It("General error returns 500", func() { + req := httptest.NewRequest("GET", "/wopi/avatars/user-123", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("userID", "user-123") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + w := httptest.NewRecorder() + + fc.On("GetAvatar", mock.Anything, "user-123").Times(1). + Return(nil, errors.New("unexpected failure")) + + httpAdapter.GetAvatar(w, req) + Expect(w.Result().StatusCode).To(Equal(500)) + }) + + It("Success writes Content-Type, Cache-Control and body", func() { + req := httptest.NewRequest("GET", "/wopi/avatars/user-123", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("userID", "user-123") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + w := httptest.NewRecorder() + + avatarData := []byte{0xFF, 0xD8, 0xFF, 0xE0} // fake JPEG bytes + fc.On("GetAvatar", mock.Anything, "user-123").Times(1).Return( + &connector.ConnectorResponse{ + Status: 200, + Headers: map[string]string{ + "Content-Type": "image/jpeg", + "Cache-Control": "public, max-age=300", + }, + Body: avatarData, + }, nil) + + httpAdapter.GetAvatar(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(200)) + Expect(resp.Header.Get("Content-Type")).To(Equal("image/jpeg")) + Expect(resp.Header.Get("Cache-Control")).To(Equal("public, max-age=300")) + body, _ := io.ReadAll(resp.Body) + Expect(body).To(Equal(avatarData)) + }) + }) + Describe("PutRelativeFile", func() { It("Connector error", func() { contentBody := "this is the new fake content" diff --git a/services/collaboration/pkg/middleware/wopicontext.go b/services/collaboration/pkg/middleware/wopicontext.go index 37724ea7c3..15cadb5c06 100644 --- a/services/collaboration/pkg/middleware/wopicontext.go +++ b/services/collaboration/pkg/middleware/wopicontext.go @@ -143,22 +143,28 @@ func WopiContextAuthMiddleware(cfg *config.Config, st microstore.Store, next htt Logger() ctx = wopiLogger.WithContext(ctx) - hashedRef := helpers.HashResourceId(claims.WopiContext.FileReference.GetResourceId()) - fileID := parseWopiFileID(cfg, r.URL.Path) - if claims.WopiContext.TemplateReference != nil { - hashedTemplateRef := helpers.HashResourceId(claims.WopiContext.TemplateReference.GetResourceId()) - // the fileID could be one of the references within the access token if both are set - // because we can use the access token to get the contents of the template file - if fileID != hashedTemplateRef && fileID != hashedRef { - wopiLogger.Error().Msg("file reference in the URL doesn't match the one inside the access token") - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - } else { - if fileID != hashedRef { - wopiLogger.Error().Msg("file reference in the URL doesn't match the one inside the access token") - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return + // Validate that the file ID in the URL matches the WOPI token's file + // reference. This check only applies to /wopi/files/ and /wopi/templates/ + // paths. Other WOPI-authenticated endpoints (e.g. /wopi/avatars/) don't + // carry a file ID in the URL — they only need a valid WOPI token. + if strings.Contains(r.URL.Path, "/files/") || strings.Contains(r.URL.Path, "/templates/") { + hashedRef := helpers.HashResourceId(claims.WopiContext.FileReference.GetResourceId()) + fileID := parseWopiFileID(cfg, r.URL.Path) + if claims.WopiContext.TemplateReference != nil { + hashedTemplateRef := helpers.HashResourceId(claims.WopiContext.TemplateReference.GetResourceId()) + // the fileID could be one of the references within the access token if both are set + // because we can use the access token to get the contents of the template file + if fileID != hashedTemplateRef && fileID != hashedRef { + wopiLogger.Error().Msg("file reference in the URL doesn't match the one inside the access token") + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + } else { + if fileID != hashedRef { + wopiLogger.Error().Msg("file reference in the URL doesn't match the one inside the access token") + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } } } diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go index daba9cf940..bd8953532e 100644 --- a/services/collaboration/pkg/server/http/server.go +++ b/services/collaboration/pkg/server/http/server.go @@ -194,5 +194,20 @@ func prepareRoutes(r *chi.Mux, options Options) { adapter.GetFile(w, r) }) }) + + // Avatar proxy: serves user avatars authenticated by the WOPI token. + // Collabora loads avatars via img.src (plain GET, no auth headers), + // so the token must be in the URL query string. + r.Route("/avatars/{userID}", func(r chi.Router) { + r.Use( + func(h stdhttp.Handler) stdhttp.Handler { + return colabmiddleware.WopiContextAuthMiddleware(options.Config, options.Store, h) + }, + colabmiddleware.CollaborationTracingMiddleware, + ) + r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + adapter.GetAvatar(w, r) + }) + }) }) }