diff --git a/services/collaboration/pkg/config/cs3api.go b/services/collaboration/pkg/config/cs3api.go index c9234ce3c1..8eb6ed5904 100644 --- a/services/collaboration/pkg/config/cs3api.go +++ b/services/collaboration/pkg/config/cs3api.go @@ -9,9 +9,10 @@ import ( // CS3Api defines the available configuration in order to access to the CS3 gateway. type CS3Api struct { Gateway Gateway `yaml:"gateway"` - DataGateway DataGateway `yaml:"datagateway"` - GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"` - APPRegistrationInterval time.Duration `yaml:"app_registration_interval" env:"COLLABORATION_CS3API_APP_REGISTRATION_INTERVAL" desc:"The interval at which the app provider registers itself." introductionVersion:"4.0.0"` + DataGateway DataGateway `yaml:"datagateway"` + GraphEndpoint string `yaml:"graph_endpoint" env:"COLLABORATION_CS3API_GRAPH_ENDPOINT" desc:"The internal HTTP endpoint of the Graph service, used to fetch user profile photos for avatars." introductionVersion:"4.0.0"` + GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"` + APPRegistrationInterval time.Duration `yaml:"app_registration_interval" env:"COLLABORATION_CS3API_APP_REGISTRATION_INTERVAL" desc:"The interval at which the app provider registers itself." introductionVersion:"4.0.0"` } // Gateway defines the available configuration for the CS3 API gateway diff --git a/services/collaboration/pkg/config/defaults/defaultconfig.go b/services/collaboration/pkg/config/defaults/defaultconfig.go index 20aaf45b86..cdb30a44c1 100644 --- a/services/collaboration/pkg/config/defaults/defaultconfig.go +++ b/services/collaboration/pkg/config/defaults/defaultconfig.go @@ -65,6 +65,7 @@ func DefaultConfig() *config.Config { DataGateway: config.DataGateway{ Insecure: false, }, + GraphEndpoint: "http://127.0.0.1:9120/graph", APPRegistrationInterval: 30 * time.Second, }, } diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index 20f516f369..bede7b8772 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -1302,6 +1302,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 { @@ -1339,6 +1351,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}) diff --git a/services/collaboration/pkg/connector/fileconnector_test.go b/services/collaboration/pkg/connector/fileconnector_test.go index 74efa85f91..c1cd3662cd 100644 --- a/services/collaboration/pkg/connector/fileconnector_test.go +++ b/services/collaboration/pkg/connector/fileconnector_test.go @@ -1994,13 +1994,24 @@ var _ = Describe("FileConnector", func() { PostMessageOrigin: "https://cloud.opencloud.test", EnableInsertRemoteImage: 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{ diff --git a/services/collaboration/pkg/connector/fileinfo/collabora.go b/services/collaboration/pkg/connector/fileinfo/collabora.go index d23e4e1a4b..a3e0ee2a1c 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: @@ -62,7 +70,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 @@ -131,7 +140,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 8791449d36..2429929641 100644 --- a/services/collaboration/pkg/connector/fileinfo/fileinfo.go +++ b/services/collaboration/pkg/connector/fileinfo/fileinfo.go @@ -113,7 +113,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..5e097e758e 100644 --- a/services/collaboration/pkg/connector/httpadapter.go +++ b/services/collaboration/pkg/connector/httpadapter.go @@ -3,13 +3,18 @@ package connector import ( "encoding/json" "errors" + "io" "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/opencloud/services/collaboration/pkg/middleware" + revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" "github.com/rs/zerolog" microstore "go-micro.dev/v4/store" @@ -41,12 +46,14 @@ const ( type HttpAdapter struct { con ConnectorService locks locks.LockParser + cfg *config.Config } // 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 { httpAdapter := &HttpAdapter{ + cfg: cfg, con: NewConnector( NewFileConnector(gws, cfg, st), NewContentConnector(gws, cfg), @@ -301,6 +308,66 @@ 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. +// +// Internally, the handler calls the Graph service directly (bypassing the +// proxy) and authenticates with the reva access token via the x-access-token +// header — the same mechanism the proxy uses when forwarding requests to +// backend services. +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 + } + + wopiContext, err := middleware.WopiContextFromCtx(r.Context()) + if err != nil { + logger.Error().Err(err).Msg("GetAvatar: missing WOPI context") + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + // Build the internal Graph API URL for the user's photo. + // We call the Graph service directly (not through the proxy) so we can + // authenticate with the reva token via x-access-token. + graphEndpoint := h.cfg.CS3Api.GraphEndpoint + avatarURL := graphEndpoint + "/v1.0/users/" + userID + "/photo/$value" + + httpReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, avatarURL, nil) + if err != nil { + logger.Error().Err(err).Msg("GetAvatar: failed to create request") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + httpReq.Header.Set(revactx.TokenHeader, wopiContext.AccessToken) + + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + logger.Error().Err(err).Msg("GetAvatar: failed to fetch avatar from Graph API") + http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway) + return + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + logger.Warn().Int("status", httpResp.StatusCode).Msg("GetAvatar: Graph API returned non-200") + http.Error(w, http.StatusText(httpResp.StatusCode), httpResp.StatusCode) + return + } + + if ct := httpResp.Header.Get("Content-Type"); ct != "" { + w.Header().Set("Content-Type", ct) + } + w.Header().Set("Cache-Control", "public, max-age=300") + w.WriteHeader(http.StatusOK) + io.Copy(w, httpResp.Body) +} + func (h *HttpAdapter) writeConnectorResponse(w http.ResponseWriter, r *http.Request, response *ConnectorResponse) { jsonBody := []byte{} if response.Body != nil { 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) + }) + }) }) }