feat(collaboration): add UserExtraInfo with avatar and mail to CheckFileInfo

Add UserExtraInfo (avatar + mail) to the WOPI CheckFileInfo response for
authenticated, non-public-share users.

UserExtraInfo format (per Collabora SDK):
https://sdk.collaboraonline.com/docs/advanced_integration.html#userextrainfo

```json
{
  "avatar": "http://url/to/user/avatar",
  "mail": "user@server.com"
}
```

After this change, CheckFileInfo returns:

```json
{
  "BaseFileName": "Pedro-filled-hazcom.docx",
  "UserFriendlyName": "Admin",
  "UserId": "346364...39323030",
  "UserCanWrite": true,
  "UserCanRename": true,
  "IsAdminUser": true,
  "EnableInsertRemoteImage": true,
  "EnableInsertRemoteFile": true,
  "EnableOwnerTermination": true,
  "UserExtraInfo": {
    "avatar": "https://host:9300/wopi/avatars/{userID}?access_token={wopiToken}",
    "mail": "admin@example.org"
  },
  "PostMessageOrigin": "https://localhost:9200",
  "message": "CheckFileInfo: success"
}
```

Avatars are served via a new /wopi/avatars/{userID} endpoint on the
collaboration service, authenticated by the WOPI token. The endpoint
calls the Graph service directly (bypassing the proxy) using the reva
access token via x-access-token header.

All tests pass:
go test ./services/collaboration/... ./services/graph/... ./services/proxy/...

Signed-off-by: Pedro Pinto Silva <pedro.silva@collabora.com>
This commit is contained in:
Pedro Pinto Silva
2026-04-01 16:05:36 +02:00
parent 0c8829c15d
commit 864e20028f
9 changed files with 162 additions and 23 deletions

View File

@@ -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

View File

@@ -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,
},
}

View File

@@ -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})

View File

@@ -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{

View File

@@ -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)

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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
}
}
}

View File

@@ -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)
})
})
})
}