mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-04-20 07:17:42 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user