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/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index bede7b8772..1b6dcd9ba7 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "fmt" "io" + "net/http" "net/url" "path" "strings" @@ -94,6 +95,10 @@ 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. @@ -1539,3 +1544,61 @@ 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") + } + + avatarURL := f.cfg.CS3Api.GraphEndpoint + "/v1.0/users/" + userID + "/photo/$value" + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, avatarURL, nil) + if err != nil { + logger.Error().Err(err).Msg("GetAvatar: failed to create request") + return nil, NewConnectorError(http.StatusInternalServerError, "failed to create request") + } + httpReq.Header.Set(ctxpkg.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") + return nil, NewConnectorError(http.StatusBadGateway, "failed to fetch avatar") + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + logger.Warn().Int("status", httpResp.StatusCode).Msg("GetAvatar: Graph API returned non-200") + return nil, NewConnectorError(httpResp.StatusCode, http.StatusText(httpResp.StatusCode)) + } + + data, err := io.ReadAll(httpResp.Body) + 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 + } + + if cl := httpResp.Header.Get("Content-Length"); cl != "" { + headers["Content-Length"] = cl + } + + return &ConnectorResponse{ + Status: http.StatusOK, + Headers: headers, + Body: data, + }, nil +} diff --git a/services/collaboration/pkg/connector/httpadapter.go b/services/collaboration/pkg/connector/httpadapter.go index 5e097e758e..454a664a6d 100644 --- a/services/collaboration/pkg/connector/httpadapter.go +++ b/services/collaboration/pkg/connector/httpadapter.go @@ -3,7 +3,6 @@ package connector import ( "encoding/json" "errors" - "io" "net/http" "strconv" @@ -13,8 +12,6 @@ import ( "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" @@ -46,14 +43,12 @@ 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), @@ -312,11 +307,6 @@ func (h *HttpAdapter) RenameFile(w http.ResponseWriter, r *http.Request) { // 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") @@ -325,47 +315,36 @@ func (h *HttpAdapter) GetAvatar(w http.ResponseWriter, r *http.Request) { return } - wopiContext, err := middleware.WopiContextFromCtx(r.Context()) + fileCon := h.con.GetFileConnector() + response, err := fileCon.GetAvatar(r.Context(), userID) if err != nil { - logger.Error().Err(err).Msg("GetAvatar: missing WOPI context") - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + 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) +} - // 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) +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.Error().Err(err).Msg("GetAvatar: failed to create request") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return + 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") } - 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) {