feature: add profile photos graph service and api

This commit is contained in:
Florian Schade
2025-05-14 16:59:42 +02:00
parent 6e4cbf2230
commit eccc900918
8 changed files with 217 additions and 238 deletions

View File

@@ -1,82 +0,0 @@
package systemstorageclient
import (
"context"
"path"
"sync"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/metadata"
)
var (
managerName = "systemdata"
)
type SystemDataStorageClient struct {
mds metadata.Storage
l *sync.Mutex
}
func (s *SystemDataStorageClient) SimpleDownload(ctx context.Context, userID, identifier string) ([]byte, error) {
//TODO implement me
panic("implement me")
}
func (s *SystemDataStorageClient) SimpleUpload(ctx context.Context, userID, identifier string, content []byte) error {
return s.mds.SimpleUpload(ctx, path.Join(userID, identifier), content)
}
func (s *SystemDataStorageClient) Delete(ctx context.Context, userID, identifier string) error {
//TODO implement me
panic("implement me")
}
func (s *SystemDataStorageClient) ReadDir(ctx context.Context, userID, identifier string) ([]string, error) {
//TODO implement me
panic("implement me")
}
func (s *SystemDataStorageClient) MakeDirIfNotExist(ctx context.Context, userID, identifier string) error {
//TODO implement me
panic("implement me")
}
// New initialize the store once, later calls are noops
func (s *SystemDataStorageClient) New(ctx context.Context,
logger *log.Logger,
scope, namespace string,
gatewayAddress, storageAddress, systemUserID, systemUserIDP, systemUserAPIKey string,
) {
if s.mds != nil {
return
}
s.l.Lock()
defer s.l.Unlock()
if s.mds != nil {
return
}
mds, err := metadata.NewCS3Storage(gatewayAddress, storageAddress, systemUserID, systemUserIDP, systemUserAPIKey)
if err != nil {
logger.Fatal().Err(err).Msg("could not create profile storage client")
}
s.mds = mds
}
// NewProfileStorageClient creates a new ProfileStorageClient
func NewSystemStorageClient(scope, nameSpace string,
logger *log.Logger,
gatewayAddress, storageAddress, systemUserID, systemUserIDP, systemUserAPIKey string) *SystemDataStorageClient {
// scope: the scope the data should be persistet in (e.g. user)
// namespace: the namespace the data should be persistet in (e.g. profilephoto)
// results in the following path: /<scope>/*/<namespace>/*
// e.g. /user/<uid>/profilephoto/profilephoto.jpg
sdsci := &SystemDataStorageClient{}
sdsci.New(context.TODO(), logger, scope, nameSpace, gatewayAddress, storageAddress, systemUserID, systemUserIDP, systemUserAPIKey)
return sdsci
}

View File

@@ -38,7 +38,7 @@ type Config struct {
Context context.Context `yaml:"-"`
SystemStorageClient SystemStorageClient `yaml:"system_storage_client"`
Metadata Metadata `yaml:"metadata_config"`
}
type Spaces struct {
@@ -156,13 +156,12 @@ type ServiceAccount struct {
ServiceAccountSecret string `yaml:"service_account_secret" env:"OC_SERVICE_ACCOUNT_SECRET;GRAPH_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"1.0.0"`
}
// SystemStorageClient configures the metadata store to use
type SystemStorageClient struct {
GatewayAddress string `yaml:"gateway_addr" env:"GRAPH_STORAGE_GATEWAY_GRPC_ADDR;STORAGE_GATEWAY_GRPC_ADDR" desc:"GRPC address of the STORAGE-SYSTEM service." introductionVersion:"1.0.0"`
StorageAddress string `yaml:"storage_addr" env:"GRAPH_STORAGE_GRPC_ADDR;STORAGE_GRPC_ADDR" desc:"GRPC address of the STORAGE-SYSTEM service." introductionVersion:"1.0.0"`
// Metadata configures the metadata store to use
type Metadata struct {
GatewayAddress string `yaml:"gateway_addr" env:"GRAPH_STORAGE_GATEWAY_GRPC_ADDR;STORAGE_GATEWAY_GRPC_ADDR" desc:"GRPC address of the STORAGE-SYSTEM service." introductionVersion:"%%NEXT%%"`
StorageAddress string `yaml:"storage_addr" env:"GRAPH_STORAGE_GRPC_ADDR;STORAGE_GRPC_ADDR" desc:"GRPC address of the STORAGE-SYSTEM service." introductionVersion:"%%NEXT%%"`
SystemUserID string `yaml:"system_user_id" env:"OC_SYSTEM_USER_ID;GRAPH_SYSTEM_USER_ID" desc:"ID of the OpenCloud STORAGE-SYSTEM system user. Admins need to set the ID for the STORAGE-SYSTEM system user in this config option which is then used to reference the user. Any reasonable long string is possible, preferably this would be an UUIDv4 format." introductionVersion:"1.0.0"`
SystemUserIDP string `yaml:"system_user_idp" env:"OC_SYSTEM_USER_IDP;GRAPH_SYSTEM_USER_IDP" desc:"IDP of the OpenCloud STORAGE-SYSTEM system user." introductionVersion:"1.0.0"`
SystemUserAPIKey string `yaml:"system_user_api_key" env:"OC_SYSTEM_USER_API_KEY" desc:"API key for the STORAGE-SYSTEM system user." introductionVersion:"1.0.0"`
Cache *Cache `yaml:"cache"`
SystemUserID string `yaml:"system_user_id" env:"OC_SYSTEM_USER_ID;GRAPH_SYSTEM_USER_ID" desc:"ID of the OpenCloud STORAGE-SYSTEM system user. Admins need to set the ID for the STORAGE-SYSTEM system user in this config option which is then used to reference the user. Any reasonable long string is possible, preferably this would be an UUIDv4 format." introductionVersion:"%%NEXT%%"`
SystemUserIDP string `yaml:"system_user_idp" env:"OC_SYSTEM_USER_IDP;GRAPH_SYSTEM_USER_IDP" desc:"IDP of the OpenCloud STORAGE-SYSTEM system user." introductionVersion:"%%NEXT%%"`
SystemUserAPIKey string `yaml:"system_user_api_key" env:"OC_SYSTEM_USER_API_KEY" desc:"API key for the STORAGE-SYSTEM system user." introductionVersion:"%%NEXT%%"`
}

View File

@@ -125,16 +125,10 @@ func DefaultConfig() *config.Config {
UnifiedRoles: config.UnifiedRoles{
AvailableRoles: nil, // will be populated with defaults in EnsureDefaults
},
SystemStorageClient: config.SystemStorageClient{
Metadata: config.Metadata{
GatewayAddress: "eu.opencloud.api.storage-system",
StorageAddress: "eu.opencloud.api.storage-system",
SystemUserIDP: "internal",
Cache: &config.Cache{
Store: "memory",
Nodes: []string{"127.0.0.1:9233"},
Database: "settings-cache",
TTL: time.Minute * 10,
},
},
}
}
@@ -203,12 +197,12 @@ func EnsureDefaults(cfg *config.Config) {
}
}
if cfg.SystemStorageClient.SystemUserAPIKey == "" && cfg.Commons != nil && cfg.Commons.SystemUserAPIKey != "" {
cfg.SystemStorageClient.SystemUserAPIKey = cfg.Commons.SystemUserAPIKey
if cfg.Metadata.SystemUserAPIKey == "" && cfg.Commons != nil && cfg.Commons.SystemUserAPIKey != "" {
cfg.Metadata.SystemUserAPIKey = cfg.Commons.SystemUserAPIKey
}
if cfg.SystemStorageClient.SystemUserID == "" && cfg.Commons != nil && cfg.Commons.SystemUserID != "" {
cfg.SystemStorageClient.SystemUserID = cfg.Commons.SystemUserID
if cfg.Metadata.SystemUserID == "" && cfg.Commons != nil && cfg.Commons.SystemUserID != "" {
cfg.Metadata.SystemUserID = cfg.Commons.SystemUserID
}
}

View File

@@ -0,0 +1,163 @@
package svc
import (
"context"
"errors"
"io"
"net/http"
"github.com/go-chi/render"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/metadata"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
)
type (
// UsersUserProfilePhotoProvider is the interface that defines the methods for the user profile photo service
UsersUserProfilePhotoProvider interface {
// GetPhoto retrieves the requested photo
GetPhoto(ctx context.Context, id string) ([]byte, error)
// UpdatePhoto retrieves the requested photo
UpdatePhoto(ctx context.Context, id string, rc io.Reader) error
// DeletePhoto deletes the requested photo
DeletePhoto(ctx context.Context, id string) error
}
)
var (
// profilePhotoSpaceID is the space ID for the profile photo
profilePhotoSpaceID = "f2bdd61a-da7c-49fc-8203-0558109d1b4f"
// ErrNoBytes is returned when no bytes are found
ErrNoBytes = errors.New("no bytes")
// ErrNoUser is returned when no user is found
ErrNoUser = errors.New("no user found")
)
// UsersUserProfilePhotoService is the implementation of the UsersUserProfilePhotoProvider interface
type UsersUserProfilePhotoService struct {
storage metadata.Storage
}
// NewUsersUserProfilePhotoService creates a new UsersUserProfilePhotoService
func NewUsersUserProfilePhotoService(storage metadata.Storage) (UsersUserProfilePhotoService, error) {
if err := storage.Init(context.Background(), profilePhotoSpaceID); err != nil {
return UsersUserProfilePhotoService{}, err
}
return UsersUserProfilePhotoService{
storage: storage,
}, nil
}
// GetPhoto retrieves the requested photo
func (s UsersUserProfilePhotoService) GetPhoto(ctx context.Context, id string) ([]byte, error) {
photo, err := s.storage.SimpleDownload(ctx, id)
if err != nil {
return nil, err
}
return photo, nil
}
// DeletePhoto deletes the requested photo
func (s UsersUserProfilePhotoService) DeletePhoto(ctx context.Context, id string) error {
return s.storage.Delete(ctx, id)
}
// UpdatePhoto updates the requested photo
func (s UsersUserProfilePhotoService) UpdatePhoto(ctx context.Context, id string, rc io.Reader) error {
photo, err := io.ReadAll(rc)
if err != nil {
return err
}
if len(photo) == 0 {
return ErrNoBytes
}
return s.storage.SimpleUpload(ctx, id, photo)
}
// UsersUserProfilePhotoApi contains all photo related api endpoints
type UsersUserProfilePhotoApi struct {
logger log.Logger
usersUserProfilePhotoService UsersUserProfilePhotoProvider
}
// NewUsersUserProfilePhotoApi creates a new UsersUserProfilePhotoApi
func NewUsersUserProfilePhotoApi(usersUserProfilePhotoService UsersUserProfilePhotoProvider, logger log.Logger) (UsersUserProfilePhotoApi, error) {
return UsersUserProfilePhotoApi{
logger: log.Logger{Logger: logger.With().Str("graph api", "UsersUserProfilePhotoApi").Logger()},
usersUserProfilePhotoService: usersUserProfilePhotoService,
}, nil
}
// GetProfilePhoto provides the requested photo
func (api UsersUserProfilePhotoApi) GetProfilePhoto(w http.ResponseWriter, r *http.Request) {
id, ok := api.getUserID(w, r)
if !ok {
return
}
photo, err := api.usersUserProfilePhotoService.GetPhoto(r.Context(), id)
if err != nil {
api.logger.Debug().Err(err)
errorcode.GeneralException.Render(w, r, http.StatusNotFound, "failed to get photo")
return
}
render.Status(r, http.StatusOK)
_, _ = w.Write(photo)
}
// UpsertProfilePhoto updates or inserts (initial create) the requested photo
func (api UsersUserProfilePhotoApi) UpsertProfilePhoto(w http.ResponseWriter, r *http.Request) {
id, ok := api.getUserID(w, r)
if !ok {
return
}
if err := api.usersUserProfilePhotoService.UpdatePhoto(r.Context(), id, r.Body); err != nil {
api.logger.Debug().Err(err)
errorcode.GeneralException.Render(w, r, http.StatusNotFound, "failed to update photo")
return
}
defer func() {
_ = r.Body.Close()
}()
render.Status(r, http.StatusOK)
}
// DeleteProfilePhoto deletes the requested photo
func (api UsersUserProfilePhotoApi) DeleteProfilePhoto(w http.ResponseWriter, r *http.Request) {
id, ok := api.getUserID(w, r)
if !ok {
return
}
if err := api.usersUserProfilePhotoService.DeletePhoto(r.Context(), id); err != nil {
api.logger.Debug().Err(err)
errorcode.GeneralException.Render(w, r, http.StatusNotFound, "failed to delete photo")
return
}
render.Status(r, http.StatusOK)
}
func (api UsersUserProfilePhotoApi) getUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
u, ok := revactx.ContextGetUser(r.Context())
if !ok {
api.logger.Debug().Msg(ErrNoUser.Error())
errorcode.GeneralException.Render(w, r, http.StatusMethodNotAllowed, ErrNoUser.Error())
return "", false
}
return u.GetId().GetOpaqueId(), true
}

View File

@@ -0,0 +1,13 @@
package svc_test
import (
"testing"
)
func TestNewUsersUserProfilePhotoApi(t *testing.T) {
panic("add UsersUserProfilePhotoApi tests")
}
func TestNewUsersUserProfilePhotoService(t *testing.T) {
panic("add UsersUserProfilePhotoService tests")
}

View File

@@ -3,7 +3,6 @@ package svc
import (
"context"
"errors"
"github.com/opencloud-eu/opencloud/pkg/systemstorageclient"
"net/http"
"net/url"
"path"
@@ -68,7 +67,6 @@ type Graph struct {
keycloakClient keycloak.Client
historyClient ehsvc.EventHistoryService
traceProvider trace.TracerProvider
sdsc systemstorageclient.SystemDataStorageClient
}
// ServeHTTP implements the Service interface.

View File

@@ -1,129 +0,0 @@
package svc
import (
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/opencloud-eu/opencloud/pkg/systemstorageclient"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"io"
"net/http"
"net/url"
)
var (
namespace = "profilephoto"
scope = "user"
identifier = "profilephoto"
)
// GetMePhoto implements the Service interface
func (g Graph) GetMePhoto(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Msg("GetMePhoto called")
u, ok := revactx.ContextGetUser(r.Context())
if !ok {
logger.Debug().Msg("could not get user: user not in context")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "user not in context")
return
}
g.getPhoto(w, r, u.GetId())
}
// GetPhoto implements the Service interface
func (g Graph) GetPhoto(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Msg("GetPhoto called")
userID, err := url.PathUnescape(chi.URLParam(r, "userID"))
if err != nil {
logger.Debug().Err(err).Str("userID", chi.URLParam(r, "userID")).Msg("could not get drive: unescaping drive id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
return
}
g.getPhoto(w, r, &userpb.UserId{
OpaqueId: userID,
})
}
func (g Graph) getPhoto(w http.ResponseWriter, r *http.Request, u *userpb.UserId) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Msg("GetPhoto called")
g.getSystemStorageClient()
photo, err := g.sdsc.SimpleDownload(r.Context(), u.GetOpaqueId(), identifier)
if err != nil {
render.Status(r, http.StatusNotFound)
render.JSON(w, r, nil)
}
render.Status(r, http.StatusOK)
render.JSON(w, r, photo)
}
// UpdateMePhoto implements the Service interface
func (g Graph) UpdateMePhoto(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Msg("UpdateMePhoto called")
u, ok := revactx.ContextGetUser(r.Context())
if !ok {
logger.Debug().Msg("could not get user: user not in context")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "user not in context")
return
}
g.updatePhoto(w, r, u.GetId())
}
// UpdatePhoto implements the Service interface
func (g Graph) UpdatePhoto(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Msg("UpdatePhoto called")
userID, err := url.PathUnescape(chi.URLParam(r, "userID"))
if err != nil {
logger.Debug().Err(err).Str("userID", chi.URLParam(r, "userID")).Msg("could not get drive: unescaping drive id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
return
}
g.updatePhoto(w, r, &userpb.UserId{
OpaqueId: userID,
})
}
func (g Graph) updatePhoto(w http.ResponseWriter, r *http.Request, u *userpb.UserId) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Debug().Msg("UpdatePhoto called")
client := g.getSystemStorageClient()
content, err := io.ReadAll(r.Body)
if err != nil {
logger.Debug().Err(err).Msg("could not read body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "could not read body")
return
}
if len(content) == 0 {
logger.Debug().Msg("could not read body: empty body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "empty body")
return
}
err = client.SimpleUpload(r.Context(), u.GetOpaqueId(), identifier, content)
if err != nil {
logger.Debug().Err(err).Msg("could not upload photo")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not upload photo")
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, nil)
}
func (g Graph) getSystemStorageClient() systemstorageclient.SystemDataStorageClient {
// TODO: this needs a check if the client is already initialized and if not, initialize it
g.sdsc = *systemstorageclient.NewSystemStorageClient(
scope,
namespace,
g.logger,
g.config.SystemStorageClient.GatewayAddress,
g.config.SystemStorageClient.StorageAddress,
g.config.SystemStorageClient.SystemUserID,
g.config.SystemStorageClient.SystemUserIDP,
g.config.SystemStorageClient.SystemUserAPIKey,
)
return g.sdsc
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/metadata"
"github.com/opencloud-eu/reva/v2/pkg/store"
"github.com/opencloud-eu/reva/v2/pkg/utils"
@@ -225,6 +226,27 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
return svc, err
}
storage, err := metadata.NewCS3Storage(
options.Config.Metadata.GatewayAddress,
options.Config.Metadata.StorageAddress,
options.Config.Metadata.SystemUserID,
options.Config.Metadata.SystemUserIDP,
options.Config.Metadata.SystemUserAPIKey,
)
if err != nil {
return svc, err
}
usersUserProfilePhotoService, err := NewUsersUserProfilePhotoService(storage)
if err != nil {
return svc, err
}
usersUserProfilePhotoApi, err := NewUsersUserProfilePhotoApi(usersUserProfilePhotoService, options.Logger)
if err != nil {
return svc, err
}
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
r.Use(middleware.StripSlashes)
@@ -293,9 +315,12 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
})
r.Get("/drives", svc.GetDrives(APIVersion_1))
r.Post("/changePassword", svc.ChangeOwnPassword)
r.Get("/photo", svc.GetMePhoto)
r.Put("/photo", svc.UpdateMePhoto)
r.Patch("/photo", svc.UpdateMePhoto)
r.Route("/photo", func(r chi.Router) {
r.Get("/", usersUserProfilePhotoApi.GetProfilePhoto)
r.Put("/", usersUserProfilePhotoApi.UpsertProfilePhoto)
r.Patch("/", usersUserProfilePhotoApi.UpsertProfilePhoto)
r.Delete("/", usersUserProfilePhotoApi.DeleteProfilePhoto)
})
})
r.Route("/users", func(r chi.Router) {
r.Get("/", svc.GetUsers)
@@ -313,8 +338,6 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
r.Delete("/{appRoleAssignmentID}", svc.DeleteAppRoleAssignment)
})
}
r.Get("/photo", svc.GetPhoto)
r.Put("/photo", svc.UpdatePhoto)
})
})
r.Route("/groups", func(r chi.Router) {