mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-04-16 13:27:37 -04:00
Merge pull request #2560 from pedropintosilva/feat/add-userextrainfo-to-checkfileinfo
feat(collaboration): add UserExtraInfo with avatar and mail to CheckFileInfo
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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