From eccc900918f33a9cfd94defb71e3a3f2e4084459 Mon Sep 17 00:00:00 2001 From: Florian Schade Date: Wed, 14 May 2025 16:59:42 +0200 Subject: [PATCH] feature: add profile photos graph service and api --- .../systemstorageclient.go | 82 --------- services/graph/pkg/config/config.go | 17 +- .../pkg/config/defaults/defaultconfig.go | 16 +- .../v0/api_users_user_profile_photo.go | 163 ++++++++++++++++++ .../v0/api_users_user_profile_photo_test.go | 13 ++ services/graph/pkg/service/v0/graph.go | 2 - services/graph/pkg/service/v0/photo.go | 129 -------------- services/graph/pkg/service/v0/service.go | 33 +++- 8 files changed, 217 insertions(+), 238 deletions(-) delete mode 100644 pkg/systemstorageclient/systemstorageclient.go create mode 100644 services/graph/pkg/service/v0/api_users_user_profile_photo.go create mode 100644 services/graph/pkg/service/v0/api_users_user_profile_photo_test.go delete mode 100644 services/graph/pkg/service/v0/photo.go diff --git a/pkg/systemstorageclient/systemstorageclient.go b/pkg/systemstorageclient/systemstorageclient.go deleted file mode 100644 index 99b063e02..000000000 --- a/pkg/systemstorageclient/systemstorageclient.go +++ /dev/null @@ -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: //*//* - // e.g. /user//profilephoto/profilephoto.jpg - - sdsci := &SystemDataStorageClient{} - sdsci.New(context.TODO(), logger, scope, nameSpace, gatewayAddress, storageAddress, systemUserID, systemUserIDP, systemUserAPIKey) - return sdsci -} diff --git a/services/graph/pkg/config/config.go b/services/graph/pkg/config/config.go index 3ae85a571..de8adbc67 100644 --- a/services/graph/pkg/config/config.go +++ b/services/graph/pkg/config/config.go @@ -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%%"` } diff --git a/services/graph/pkg/config/defaults/defaultconfig.go b/services/graph/pkg/config/defaults/defaultconfig.go index bc30304e2..4ed274c70 100644 --- a/services/graph/pkg/config/defaults/defaultconfig.go +++ b/services/graph/pkg/config/defaults/defaultconfig.go @@ -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 } } diff --git a/services/graph/pkg/service/v0/api_users_user_profile_photo.go b/services/graph/pkg/service/v0/api_users_user_profile_photo.go new file mode 100644 index 000000000..3f4b233c8 --- /dev/null +++ b/services/graph/pkg/service/v0/api_users_user_profile_photo.go @@ -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 +} diff --git a/services/graph/pkg/service/v0/api_users_user_profile_photo_test.go b/services/graph/pkg/service/v0/api_users_user_profile_photo_test.go new file mode 100644 index 000000000..a51c3b2b5 --- /dev/null +++ b/services/graph/pkg/service/v0/api_users_user_profile_photo_test.go @@ -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") +} diff --git a/services/graph/pkg/service/v0/graph.go b/services/graph/pkg/service/v0/graph.go index 4cc157163..dbcbef72b 100644 --- a/services/graph/pkg/service/v0/graph.go +++ b/services/graph/pkg/service/v0/graph.go @@ -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. diff --git a/services/graph/pkg/service/v0/photo.go b/services/graph/pkg/service/v0/photo.go deleted file mode 100644 index 0c46537f3..000000000 --- a/services/graph/pkg/service/v0/photo.go +++ /dev/null @@ -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 -} diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index 89ad821bb..919f07aa6 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -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) {