Initial QSfera import
This commit is contained in:
@@ -0,0 +1,991 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
grouppb "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
|
||||
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
ocmprovider "github.com/cs3org/go-cs3apis/cs3/ocm/provider/v1beta1"
|
||||
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
|
||||
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/publicshare"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/share"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
"github.com/qsfera/server/pkg/l10n"
|
||||
l10n_pkg "github.com/qsfera/server/services/graph/pkg/l10n"
|
||||
"github.com/qsfera/server/services/graph/pkg/odata"
|
||||
|
||||
"github.com/qsfera/server/pkg/conversions"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/graph/pkg/config"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
"github.com/qsfera/server/services/graph/pkg/identity"
|
||||
"github.com/qsfera/server/services/graph/pkg/identity/cache"
|
||||
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
|
||||
"github.com/qsfera/server/services/graph/pkg/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
invalidIdMsg = "invalid driveID or itemID"
|
||||
parseDriveIDErrMsg = "could not parse driveID"
|
||||
federatedRolesODataFilter = "@libre.graph.permissions.roles.allowedValues/rolePermissions/any(p:contains(p/condition, '@Subject.UserType==\"Federated\"'))"
|
||||
noLinksODataFilter = "grantedToV2 ne ''"
|
||||
)
|
||||
|
||||
// DriveItemPermissionsProvider contains the methods related to handling permissions on drive items
|
||||
type DriveItemPermissionsProvider interface {
|
||||
Invite(ctx context.Context, resourceId *storageprovider.ResourceId, invite libregraph.DriveItemInvite) (libregraph.Permission, error)
|
||||
SpaceRootInvite(ctx context.Context, driveID *storageprovider.ResourceId, invite libregraph.DriveItemInvite) (libregraph.Permission, error)
|
||||
ListPermissions(ctx context.Context, itemID *storageprovider.ResourceId, queryOptions ListPermissionsQueryOptions) (libregraph.CollectionOfPermissionsWithAllowedValues, error)
|
||||
ListSpaceRootPermissions(ctx context.Context, driveID *storageprovider.ResourceId, queryOptions ListPermissionsQueryOptions) (libregraph.CollectionOfPermissionsWithAllowedValues, error)
|
||||
DeletePermission(ctx context.Context, itemID *storageprovider.ResourceId, permissionID string) error
|
||||
DeleteSpaceRootPermission(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string) error
|
||||
UpdatePermission(ctx context.Context, itemID *storageprovider.ResourceId, permissionID string, newPermission libregraph.Permission) (libregraph.Permission, error)
|
||||
UpdateSpaceRootPermission(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string, newPermission libregraph.Permission) (libregraph.Permission, error)
|
||||
CreateLink(ctx context.Context, driveItemID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error)
|
||||
CreateSpaceRootLink(ctx context.Context, driveID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error)
|
||||
SetPublicLinkPassword(ctx context.Context, driveItemID *storageprovider.ResourceId, permissionID string, password string) (libregraph.Permission, error)
|
||||
SetPublicLinkPasswordOnSpaceRoot(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string, password string) (libregraph.Permission, error)
|
||||
}
|
||||
|
||||
// DriveItemPermissionsService contains the production business logic for everything that relates to permissions on drive items.
|
||||
type DriveItemPermissionsService struct {
|
||||
BaseGraphService
|
||||
}
|
||||
|
||||
type permissionType int
|
||||
|
||||
const (
|
||||
Unknown permissionType = iota
|
||||
Public
|
||||
User
|
||||
Space
|
||||
OCM
|
||||
)
|
||||
|
||||
type ListPermissionsQueryOptions struct {
|
||||
Count bool
|
||||
NoValues bool
|
||||
NoLinkPermissions bool
|
||||
FilterFederatedRoles bool
|
||||
SelectedAttrs []string
|
||||
}
|
||||
|
||||
// NewDriveItemPermissionsService creates a new DriveItemPermissionsService
|
||||
func NewDriveItemPermissionsService(logger log.Logger, gatewaySelector pool.Selectable[gateway.GatewayAPIClient], identityCache cache.IdentityCache, config *config.Config) (DriveItemPermissionsService, error) {
|
||||
publicBaseURL, err := url.Parse(config.Spaces.WebDavBase)
|
||||
if err != nil {
|
||||
return DriveItemPermissionsService{}, fmt.Errorf("could not parse graph.spaces.webdav_base: %w", err)
|
||||
}
|
||||
return DriveItemPermissionsService{
|
||||
BaseGraphService: BaseGraphService{
|
||||
logger: &log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemService").Logger()},
|
||||
gatewaySelector: gatewaySelector,
|
||||
identityCache: identityCache,
|
||||
config: config,
|
||||
availableRoles: unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(config.UnifiedRoles.AvailableRoles...)),
|
||||
publicBaseURL: publicBaseURL,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Invite invites a user to a drive item.
|
||||
func (s DriveItemPermissionsService) Invite(ctx context.Context, resourceId *storageprovider.ResourceId, invite libregraph.DriveItemInvite) (libregraph.Permission, error) {
|
||||
tenantId := revactx.ContextMustGetUser(ctx).GetId().GetTenantId()
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
|
||||
statResponse, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: resourceId}})
|
||||
if err := errorcode.FromStat(statResponse, err); err != nil {
|
||||
s.logger.Warn().Err(err).Interface("stat.res", statResponse).Msg("stat failed")
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
|
||||
var condition string
|
||||
if condition, err = roleConditionForResourceType(statResponse.GetInfo()); err != nil {
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
|
||||
unifiedRolePermissions := []*libregraph.UnifiedRolePermission{{AllowedResourceActions: invite.LibreGraphPermissionsActions}}
|
||||
for _, roleID := range invite.GetRoles() {
|
||||
// only allow roles that are enabled in the config
|
||||
if !slices.Contains(s.config.UnifiedRoles.AvailableRoles, roleID) {
|
||||
return libregraph.Permission{}, unifiedrole.ErrUnknownRole
|
||||
}
|
||||
|
||||
role, err := unifiedrole.GetRole(unifiedrole.RoleFilterIDs(roleID))
|
||||
if err != nil {
|
||||
s.logger.Debug().Err(err).Interface("role", invite.GetRoles()[0]).Msg("unable to convert requested role")
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
|
||||
allowedResourceActions := unifiedrole.GetAllowedResourceActions(role, condition)
|
||||
if len(allowedResourceActions) == 0 && role.GetId() != unifiedrole.UnifiedRoleDeniedID {
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "role not applicable to this resource")
|
||||
}
|
||||
|
||||
unifiedRolePermissions = append(unifiedRolePermissions, conversions.ToPointerSlice(role.GetRolePermissions())...)
|
||||
}
|
||||
|
||||
driveRecipient := invite.GetRecipients()[0]
|
||||
|
||||
objectID := driveRecipient.GetObjectId()
|
||||
cs3ResourcePermissions := unifiedrole.PermissionsToCS3ResourcePermissions(unifiedRolePermissions)
|
||||
|
||||
permission := &libregraph.Permission{}
|
||||
availableRoles := unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(s.config.UnifiedRoles.AvailableRoles...))
|
||||
if role := unifiedrole.CS3ResourcePermissionsToRole(availableRoles, cs3ResourcePermissions, condition, false); role != nil {
|
||||
permission.Roles = []string{role.GetId()}
|
||||
}
|
||||
|
||||
if len(permission.GetRoles()) == 0 {
|
||||
permission.LibreGraphPermissionsActions = unifiedrole.CS3ResourcePermissionsToLibregraphActions(cs3ResourcePermissions)
|
||||
}
|
||||
|
||||
var shareid string
|
||||
var expiration *types.Timestamp
|
||||
var cTime *types.Timestamp
|
||||
switch driveRecipient.GetLibreGraphRecipientType() {
|
||||
case "group":
|
||||
group, err := s.identityCache.GetGroup(ctx, objectID)
|
||||
if err != nil {
|
||||
s.logger.Debug().Err(err).Interface("groupId", objectID).Msg("failed group lookup")
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, err.Error())
|
||||
}
|
||||
permission.GrantedToV2 = &libregraph.SharePointIdentitySet{
|
||||
Group: &libregraph.Identity{
|
||||
DisplayName: group.GetDisplayName(),
|
||||
Id: conversions.ToPointer(group.GetId()),
|
||||
},
|
||||
}
|
||||
createShareRequest := createShareRequestToGroup(group, statResponse.GetInfo(), cs3ResourcePermissions)
|
||||
if invite.ExpirationDateTime != nil {
|
||||
createShareRequest.GetGrant().Expiration = utils.TimeToTS(*invite.ExpirationDateTime)
|
||||
}
|
||||
createShareResponse, err := gatewayClient.CreateShare(ctx, createShareRequest)
|
||||
if err := errorcode.FromCS3Status(createShareResponse.GetStatus(), err); err != nil {
|
||||
s.logger.Debug().Err(err).Msg("share creation failed")
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
shareid = createShareResponse.GetShare().GetId().GetOpaqueId()
|
||||
cTime = createShareResponse.GetShare().GetCtime()
|
||||
expiration = createShareResponse.GetShare().GetExpiration()
|
||||
default:
|
||||
user, err := s.identityCache.GetCS3User(ctx, tenantId, objectID)
|
||||
if errors.Is(err, identity.ErrNotFound) && s.config.IncludeOCMSharees {
|
||||
user, err = s.identityCache.GetAcceptedCS3User(ctx, objectID)
|
||||
if err == nil && IsSpaceRoot(statResponse.GetInfo().GetId()) {
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "federated user can not become a space member")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Debug().Err(err).Interface("userId", objectID).Msg("failed user lookup")
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, err.Error())
|
||||
}
|
||||
permission.GrantedToV2 = &libregraph.SharePointIdentitySet{
|
||||
User: &libregraph.Identity{
|
||||
DisplayName: user.GetDisplayName(),
|
||||
Id: conversions.ToPointer(user.GetId().GetOpaqueId()),
|
||||
LibreGraphUserType: conversions.ToPointer(identity.CS3UserTypeToGraph(user.GetId().GetType())),
|
||||
},
|
||||
}
|
||||
|
||||
if user.GetId().GetType() == userpb.UserType_USER_TYPE_FEDERATED {
|
||||
providerInfoResp, err := gatewayClient.GetInfoByDomain(ctx, &ocmprovider.GetInfoByDomainRequest{
|
||||
Domain: user.GetId().GetIdp(),
|
||||
})
|
||||
if err = errorcode.FromCS3Status(providerInfoResp.GetStatus(), err); err != nil {
|
||||
s.logger.Error().Err(err).Msg("getting provider info failed")
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
|
||||
createShareRequest := createShareRequestToFederatedUser(user, statResponse.GetInfo().GetId(), providerInfoResp.ProviderInfo, cs3ResourcePermissions)
|
||||
if invite.ExpirationDateTime != nil {
|
||||
createShareRequest.Expiration = utils.TimeToTS(*invite.ExpirationDateTime)
|
||||
}
|
||||
createShareResponse, err := gatewayClient.CreateOCMShare(ctx, createShareRequest)
|
||||
if err = errorcode.FromCS3Status(createShareResponse.GetStatus(), err); err != nil {
|
||||
s.logger.Error().Err(err).Msg("share creation failed")
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
shareid = createShareResponse.GetShare().GetId().GetOpaqueId()
|
||||
cTime = createShareResponse.GetShare().GetCtime()
|
||||
expiration = createShareResponse.GetShare().GetExpiration()
|
||||
} else {
|
||||
createShareRequest := createShareRequestToUser(user, statResponse.GetInfo(), cs3ResourcePermissions)
|
||||
if invite.ExpirationDateTime != nil {
|
||||
createShareRequest.GetGrant().Expiration = utils.TimeToTS(*invite.ExpirationDateTime)
|
||||
}
|
||||
createShareResponse, err := gatewayClient.CreateShare(ctx, createShareRequest)
|
||||
if err = errorcode.FromCS3Status(createShareResponse.GetStatus(), err); err != nil {
|
||||
s.logger.Error().Err(err).Msg("share creation failed")
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
shareid = createShareResponse.GetShare().GetId().GetOpaqueId()
|
||||
cTime = createShareResponse.GetShare().GetCtime()
|
||||
expiration = createShareResponse.GetShare().GetExpiration()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if shareid != "" {
|
||||
permission.Id = conversions.ToPointer(shareid)
|
||||
}
|
||||
|
||||
if expiration != nil {
|
||||
permission.SetExpirationDateTime(utils.TSToTime(expiration))
|
||||
}
|
||||
|
||||
// set cTime
|
||||
if cTime != nil {
|
||||
permission.SetCreatedDateTime(cs3TimestampToTime(cTime))
|
||||
}
|
||||
|
||||
if user, ok := revactx.ContextGetUser(ctx); ok {
|
||||
userIdentity, err := userIdToIdentity(ctx, s.identityCache, tenantId, user.GetId().GetOpaqueId())
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("identity lookup failed")
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, err.Error())
|
||||
}
|
||||
permission.SetInvitation(libregraph.SharingInvitation{
|
||||
InvitedBy: &libregraph.IdentitySet{
|
||||
User: &userIdentity,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return *permission, nil
|
||||
}
|
||||
|
||||
func createShareRequestToGroup(group libregraph.Group, info *storageprovider.ResourceInfo, cs3ResourcePermissions *storageprovider.ResourcePermissions) *collaboration.CreateShareRequest {
|
||||
return &collaboration.CreateShareRequest{
|
||||
ResourceInfo: info,
|
||||
Grant: &collaboration.ShareGrant{
|
||||
Grantee: &storageprovider.Grantee{
|
||||
Type: storageprovider.GranteeType_GRANTEE_TYPE_GROUP,
|
||||
Id: &storageprovider.Grantee_GroupId{GroupId: &grouppb.GroupId{
|
||||
OpaqueId: group.GetId(),
|
||||
}},
|
||||
},
|
||||
Permissions: &collaboration.SharePermissions{
|
||||
Permissions: cs3ResourcePermissions,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createShareRequestToUser(user *userpb.User, info *storageprovider.ResourceInfo, cs3ResourcePermissions *storageprovider.ResourcePermissions) *collaboration.CreateShareRequest {
|
||||
return &collaboration.CreateShareRequest{
|
||||
ResourceInfo: info,
|
||||
Grant: &collaboration.ShareGrant{
|
||||
Grantee: &storageprovider.Grantee{
|
||||
Type: storageprovider.GranteeType_GRANTEE_TYPE_USER,
|
||||
Id: &storageprovider.Grantee_UserId{
|
||||
UserId: user.GetId(),
|
||||
},
|
||||
},
|
||||
Permissions: &collaboration.SharePermissions{
|
||||
Permissions: cs3ResourcePermissions,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createShareRequestToFederatedUser(user *userpb.User, resourceId *storageprovider.ResourceId, providerInfo *ocmprovider.ProviderInfo, cs3ResourcePermissions *storageprovider.ResourcePermissions) *ocm.CreateOCMShareRequest {
|
||||
return &ocm.CreateOCMShareRequest{
|
||||
ResourceId: resourceId,
|
||||
Grantee: &storageprovider.Grantee{
|
||||
Type: storageprovider.GranteeType_GRANTEE_TYPE_USER,
|
||||
Id: &storageprovider.Grantee_UserId{
|
||||
UserId: user.GetId(),
|
||||
},
|
||||
},
|
||||
RecipientMeshProvider: providerInfo,
|
||||
AccessMethods: []*ocm.AccessMethod{
|
||||
{
|
||||
Term: &ocm.AccessMethod_WebdavOptions{
|
||||
WebdavOptions: &ocm.WebDAVAccessMethod{
|
||||
Permissions: cs3ResourcePermissions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SpaceRootInvite handles invitation request on project spaces
|
||||
func (s DriveItemPermissionsService) SpaceRootInvite(ctx context.Context, driveID *storageprovider.ResourceId, invite libregraph.DriveItemInvite) (libregraph.Permission, error) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
|
||||
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, errorcode.FromUtilsStatusCodeError(err)
|
||||
}
|
||||
|
||||
if space.SpaceType != _spaceTypeProject {
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
|
||||
}
|
||||
|
||||
rootResourceID := space.GetRoot()
|
||||
return s.Invite(ctx, rootResourceID, invite)
|
||||
}
|
||||
|
||||
// ListPermissions lists the permissions of a driveItem
|
||||
func (s DriveItemPermissionsService) ListPermissions(ctx context.Context, itemID *storageprovider.ResourceId, queryOptions ListPermissionsQueryOptions) (libregraph.CollectionOfPermissionsWithAllowedValues, error) {
|
||||
collectionOfPermissions := libregraph.CollectionOfPermissionsWithAllowedValues{}
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return collectionOfPermissions, err
|
||||
}
|
||||
|
||||
statResponse, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: itemID}})
|
||||
if err := errorcode.FromStat(statResponse, err); err != nil {
|
||||
s.logger.Warn().Err(err).Interface("stat.res", statResponse).Msg("stat failed")
|
||||
return collectionOfPermissions, err
|
||||
}
|
||||
|
||||
var condition string
|
||||
if condition, err = roleConditionForResourceType(statResponse.GetInfo()); err != nil {
|
||||
return collectionOfPermissions, err
|
||||
}
|
||||
|
||||
permissionSet := statResponse.GetInfo().GetPermissionSet()
|
||||
allowedActions := unifiedrole.CS3ResourcePermissionsToLibregraphActions(permissionSet)
|
||||
|
||||
collectionOfPermissions = libregraph.CollectionOfPermissionsWithAllowedValues{}
|
||||
|
||||
if len(queryOptions.SelectedAttrs) == 0 || slices.Contains(queryOptions.SelectedAttrs, "@libre.graph.permissions.actions.allowedValues") {
|
||||
collectionOfPermissions.LibreGraphPermissionsActionsAllowedValues = allowedActions
|
||||
}
|
||||
|
||||
if len(queryOptions.SelectedAttrs) == 0 || slices.Contains(queryOptions.SelectedAttrs, "@libre.graph.permissions.roles.allowedValues") {
|
||||
collectionOfPermissions.LibreGraphPermissionsRolesAllowedValues = conversions.ToValueSlice(
|
||||
unifiedrole.GetRolesByPermissions(
|
||||
unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(s.config.UnifiedRoles.AvailableRoles...)),
|
||||
allowedActions,
|
||||
condition,
|
||||
queryOptions.FilterFederatedRoles,
|
||||
false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for i, definition := range collectionOfPermissions.LibreGraphPermissionsRolesAllowedValues {
|
||||
// the openapi spec defines that the rolePermissions should not be part of the response
|
||||
definition.RolePermissions = nil
|
||||
collectionOfPermissions.LibreGraphPermissionsRolesAllowedValues[i] = definition
|
||||
}
|
||||
|
||||
if len(queryOptions.SelectedAttrs) > 0 {
|
||||
// no need to fetch shares, we are only interested allowedActions and/or allowedRoles
|
||||
return collectionOfPermissions, nil
|
||||
}
|
||||
|
||||
driveItems := make(driveItemsByResourceID, 1)
|
||||
// we can use the statResponse to build the drive item before fetching the shares
|
||||
item, err := cs3ResourceToDriveItem(s.logger, s.publicBaseURL, statResponse.GetInfo())
|
||||
if err != nil {
|
||||
return collectionOfPermissions, err
|
||||
}
|
||||
driveItems[storagespace.FormatResourceID(statResponse.GetInfo().GetId())] = *item
|
||||
|
||||
var permissionsCount int
|
||||
|
||||
if IsSpaceRoot(statResponse.GetInfo().GetId()) {
|
||||
driveItems, err = s.listSpaceRootUserShares(ctx, []*collaboration.Filter{
|
||||
share.ResourceIDFilter(itemID),
|
||||
}, driveItems)
|
||||
if err != nil {
|
||||
return collectionOfPermissions, err
|
||||
}
|
||||
} else {
|
||||
// "normal" driveItem, populate user permissions via share providers
|
||||
driveItems, err = s.listUserShares(ctx, []*collaboration.Filter{
|
||||
share.ResourceIDFilter(itemID),
|
||||
}, driveItems)
|
||||
if err != nil {
|
||||
return collectionOfPermissions, err
|
||||
}
|
||||
if s.config.IncludeOCMSharees {
|
||||
driveItems, err = s.listOCMShares(ctx, []*ocm.ListOCMSharesRequest_Filter{
|
||||
{
|
||||
Type: ocm.ListOCMSharesRequest_Filter_TYPE_RESOURCE_ID,
|
||||
Term: &ocm.ListOCMSharesRequest_Filter_ResourceId{ResourceId: itemID},
|
||||
},
|
||||
}, driveItems)
|
||||
if err != nil {
|
||||
return collectionOfPermissions, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !queryOptions.NoLinkPermissions {
|
||||
// finally get public shares, which are possible for spaceroots and "normal" resources
|
||||
driveItems, err = s.listPublicShares(ctx, []*link.ListPublicSharesRequest_Filter{
|
||||
publicshare.ResourceIDFilter(itemID),
|
||||
}, driveItems)
|
||||
if err != nil {
|
||||
return collectionOfPermissions, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, driveItem := range driveItems {
|
||||
permissionsCount += len(driveItem.Permissions)
|
||||
if !queryOptions.NoValues {
|
||||
collectionOfPermissions.Value = append(collectionOfPermissions.Value, driveItem.Permissions...)
|
||||
}
|
||||
}
|
||||
|
||||
if queryOptions.Count {
|
||||
collectionOfPermissions.SetOdataCount(int32(permissionsCount))
|
||||
}
|
||||
|
||||
return collectionOfPermissions, nil
|
||||
}
|
||||
|
||||
// ListSpaceRootPermissions handles ListPermissions request on project spaces
|
||||
func (s DriveItemPermissionsService) ListSpaceRootPermissions(ctx context.Context, driveID *storageprovider.ResourceId, queryOptions ListPermissionsQueryOptions) (libregraph.CollectionOfPermissionsWithAllowedValues, error) {
|
||||
collectionOfPermissions := libregraph.CollectionOfPermissionsWithAllowedValues{}
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return collectionOfPermissions, err
|
||||
}
|
||||
|
||||
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
|
||||
if err != nil {
|
||||
return collectionOfPermissions, errorcode.FromUtilsStatusCodeError(err)
|
||||
}
|
||||
|
||||
isSupportedSpaceType := slices.Contains([]string{_spaceTypeProject, _spaceTypePersonal, _spaceTypeVirtual}, space.GetSpaceType())
|
||||
if !isSupportedSpaceType {
|
||||
return collectionOfPermissions, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
|
||||
}
|
||||
|
||||
rootResourceID := space.GetRoot()
|
||||
return s.ListPermissions(ctx, rootResourceID, queryOptions) // federated roles are not supported for spaces
|
||||
}
|
||||
|
||||
// DeletePermission deletes a permission from a drive item
|
||||
func (s DriveItemPermissionsService) DeletePermission(ctx context.Context, itemID *storageprovider.ResourceId, permissionID string) error {
|
||||
var permissionType permissionType
|
||||
|
||||
sharedResourceID, err := s.getLinkPermissionResourceID(ctx, permissionID)
|
||||
switch {
|
||||
// Check if the ID is referring to a public share
|
||||
case err == nil:
|
||||
permissionType = Public
|
||||
// If the item id is referring to a space root and this is not a public share
|
||||
// we have to deal with space permissions
|
||||
case IsSpaceRoot(itemID):
|
||||
permissionType = Space
|
||||
sharedResourceID = itemID
|
||||
err = nil
|
||||
// If this is neither a public share nor a space permission, check if this is a
|
||||
// user share
|
||||
default:
|
||||
sharedResourceID, err = s.getUserPermissionResourceID(ctx, permissionID)
|
||||
if err == nil {
|
||||
permissionType = User
|
||||
}
|
||||
}
|
||||
|
||||
if sharedResourceID == nil && s.config.IncludeOCMSharees {
|
||||
sharedResourceID, err = s.getOCMPermissionResourceID(ctx, permissionID)
|
||||
if err == nil {
|
||||
permissionType = OCM
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
return err
|
||||
case permissionType == Unknown:
|
||||
return errorcode.New(errorcode.ItemNotFound, "permission not found")
|
||||
case sharedResourceID == nil:
|
||||
return errorcode.New(errorcode.ItemNotFound, "failed to resolve resource id for shared resource")
|
||||
}
|
||||
|
||||
// The resourceID of the shared resource need to match the item ID from the Request Path
|
||||
// otherwise this is an invalid Request.
|
||||
if !utils.ResourceIDEqual(sharedResourceID, itemID) {
|
||||
s.logger.Debug().Msg("resourceID of shared does not match itemID")
|
||||
return errorcode.New(errorcode.InvalidRequest, "permissionID and itemID do not match")
|
||||
}
|
||||
|
||||
switch permissionType {
|
||||
case User, Space:
|
||||
return s.removeUserShare(ctx, permissionID)
|
||||
case Public:
|
||||
return s.removePublicShare(ctx, permissionID)
|
||||
case OCM:
|
||||
return s.removeOCMPermission(ctx, permissionID)
|
||||
default:
|
||||
// This should never be reached
|
||||
return errorcode.New(errorcode.GeneralException, "failed to delete permission")
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteSpaceRootPermission deletes a permission on the root item of a project space
|
||||
func (s DriveItemPermissionsService) DeleteSpaceRootPermission(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string) error {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
|
||||
if err != nil {
|
||||
return errorcode.FromUtilsStatusCodeError(err)
|
||||
}
|
||||
|
||||
if space.SpaceType != _spaceTypeProject {
|
||||
return errorcode.New(errorcode.InvalidRequest, "unsupported space type")
|
||||
}
|
||||
|
||||
rootResourceID := space.GetRoot()
|
||||
return s.DeletePermission(ctx, rootResourceID, permissionID)
|
||||
}
|
||||
|
||||
// UpdatePermission updates a permission on a drive item
|
||||
func (s DriveItemPermissionsService) UpdatePermission(ctx context.Context, itemID *storageprovider.ResourceId, permissionID string, newPermission libregraph.Permission) (libregraph.Permission, error) {
|
||||
oldPermission, sharedResourceID, err := s.getPermissionByID(ctx, permissionID, itemID)
|
||||
|
||||
// try to get the permission from ocm if the permission was not found first place
|
||||
if err != nil && s.config.IncludeOCMSharees {
|
||||
oldPermission, sharedResourceID, err = s.getOCMPermissionByID(ctx, permissionID, itemID)
|
||||
}
|
||||
|
||||
// if we still can't find the permission, return an error
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
|
||||
// The resourceID of the shared resource need to match the item ID from the Request Path
|
||||
// otherwise this is an invalid Request.
|
||||
if !utils.ResourceIDEqual(sharedResourceID, itemID) {
|
||||
s.logger.Debug().Msg("resourceID of shared does not match itemID")
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "permissionID and itemID do not match")
|
||||
}
|
||||
|
||||
// This is a public link
|
||||
if _, ok := oldPermission.GetLinkOk(); ok {
|
||||
updatedPermission, err := s.updatePublicLinkPermission(ctx, permissionID, itemID, &newPermission)
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
return *updatedPermission, nil
|
||||
}
|
||||
|
||||
// This is a user share
|
||||
updatedPermission, err := s.updateUserShare(ctx, permissionID, sharedResourceID, &newPermission)
|
||||
if err == nil && updatedPermission != nil {
|
||||
return *updatedPermission, nil
|
||||
}
|
||||
|
||||
// This is an ocm share
|
||||
if s.config.IncludeOCMSharees {
|
||||
updatePermission, err := s.updateOCMPermission(ctx, permissionID, itemID, &newPermission)
|
||||
if err == nil {
|
||||
return *updatePermission, nil
|
||||
}
|
||||
}
|
||||
|
||||
return libregraph.Permission{}, err
|
||||
|
||||
}
|
||||
|
||||
// UpdateSpaceRootPermission updates a permission on the root item of a project space
|
||||
func (s DriveItemPermissionsService) UpdateSpaceRootPermission(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string, newPermission libregraph.Permission) (libregraph.Permission, error) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
|
||||
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, errorcode.FromUtilsStatusCodeError(err)
|
||||
}
|
||||
|
||||
if space.SpaceType != _spaceTypeProject {
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
|
||||
}
|
||||
|
||||
rootResourceID := space.GetRoot()
|
||||
return s.UpdatePermission(ctx, rootResourceID, permissionID, newPermission)
|
||||
}
|
||||
|
||||
// DriveItemPermissionsApi is the api that registers the http endpoints which expose needed operation to the graph api.
|
||||
// the business logic is delegated to the permissions service and further down to the cs3 client.
|
||||
type DriveItemPermissionsApi struct {
|
||||
logger log.Logger
|
||||
driveItemPermissionsService DriveItemPermissionsProvider
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewDriveItemPermissionsApi creates a new DriveItemPermissionsApi
|
||||
func NewDriveItemPermissionsApi(driveItemPermissionService DriveItemPermissionsProvider, logger log.Logger, c *config.Config) (DriveItemPermissionsApi, error) {
|
||||
return DriveItemPermissionsApi{
|
||||
logger: log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemApi").Logger()},
|
||||
driveItemPermissionsService: driveItemPermissionService,
|
||||
config: c,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Invite handles DriveItemInvite requests
|
||||
func (api DriveItemPermissionsApi) Invite(w http.ResponseWriter, r *http.Request) {
|
||||
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(invalidIdMsg)
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, invalidIdMsg)
|
||||
return
|
||||
}
|
||||
|
||||
driveItemInvite := &libregraph.DriveItemInvite{}
|
||||
if err = StrictJSONUnmarshal(r.Body, driveItemInvite); err != nil {
|
||||
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := validate.ContextWithAllowedRoleIDs(r.Context(), api.config.UnifiedRoles.AvailableRoles)
|
||||
if err = validate.StructCtx(ctx, driveItemInvite); err != nil {
|
||||
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
permission, err := api.driveItemPermissionsService.Invite(ctx, itemID, *driveItemInvite)
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &ListResponse{Value: []any{permission}})
|
||||
}
|
||||
|
||||
// SpaceRootInvite handles DriveItemInvite requests on a space root
|
||||
func (api DriveItemPermissionsApi) SpaceRootInvite(w http.ResponseWriter, r *http.Request) {
|
||||
driveID, err := parseIDParam(r, "driveID")
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(parseDriveIDErrMsg)
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, parseDriveIDErrMsg)
|
||||
return
|
||||
}
|
||||
|
||||
driveItemInvite := &libregraph.DriveItemInvite{}
|
||||
if err = StrictJSONUnmarshal(r.Body, driveItemInvite); err != nil {
|
||||
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := validate.ContextWithAllowedRoleIDs(r.Context(), api.config.UnifiedRoles.AvailableRoles)
|
||||
if err = validate.StructCtx(ctx, driveItemInvite); err != nil {
|
||||
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
permission, err := api.driveItemPermissionsService.SpaceRootInvite(ctx, &driveID, *driveItemInvite)
|
||||
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &ListResponse{Value: []any{permission}})
|
||||
}
|
||||
|
||||
// ListPermissions handles ListPermissions requests
|
||||
func (api DriveItemPermissionsApi) ListPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(invalidIdMsg)
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("Error parsing ListPermissionRequest: query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var queryOptions ListPermissionsQueryOptions
|
||||
queryOptions, err = api.getListPermissionsQueryOptions(odataReq)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("Error parsing ListPermissionRequest query options")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
permissions, err := api.driveItemPermissionsService.ListPermissions(ctx, itemID, queryOptions)
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
loc := r.Header.Get(l10n.HeaderAcceptLanguage)
|
||||
w.Header().Add("Content-Language", loc)
|
||||
if loc != "" && loc != "en" {
|
||||
err := l10n_pkg.TranslateEntity(loc, "en", permissions,
|
||||
l10n.TranslateEach("LibreGraphPermissionsRolesAllowedValues",
|
||||
l10n.TranslateField("Description"),
|
||||
l10n.TranslateField("DisplayName"),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
api.logger.Error().Err(err).Msg("tranlation error")
|
||||
}
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, permissions)
|
||||
}
|
||||
|
||||
// ListSpaceRootPermissions handles ListPermissions requests on a space root
|
||||
func (api DriveItemPermissionsApi) ListSpaceRootPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
driveID, err := parseIDParam(r, "driveID")
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(parseDriveIDErrMsg)
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, parseDriveIDErrMsg)
|
||||
return
|
||||
}
|
||||
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("Error parsing ListPermissionRequest: query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var queryOptions ListPermissionsQueryOptions
|
||||
queryOptions, err = api.getListPermissionsQueryOptions(odataReq)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("Error parsing ListPermissionRequest query options")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
permissions, err := api.driveItemPermissionsService.ListSpaceRootPermissions(ctx, &driveID, queryOptions)
|
||||
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
loc := r.Header.Get(l10n.HeaderAcceptLanguage)
|
||||
w.Header().Add("Content-Language", loc)
|
||||
if loc != "" && loc != "en" {
|
||||
err := l10n_pkg.TranslateEntity(loc, "en", permissions,
|
||||
l10n.TranslateEach("LibreGraphPermissionsRolesAllowedValues",
|
||||
l10n.TranslateField("Description"),
|
||||
l10n.TranslateField("DisplayName"),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
api.logger.Error().Err(err).Msg("tranlation error")
|
||||
}
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, permissions)
|
||||
}
|
||||
|
||||
func (api DriveItemPermissionsApi) getListPermissionsQueryOptions(odataReq *godata.GoDataRequest) (ListPermissionsQueryOptions, error) {
|
||||
queryOptions := ListPermissionsQueryOptions{}
|
||||
if odataReq.Query.Filter != nil {
|
||||
switch odataReq.Query.Filter.RawValue {
|
||||
case federatedRolesODataFilter:
|
||||
queryOptions.FilterFederatedRoles = true
|
||||
case noLinksODataFilter:
|
||||
queryOptions.NoLinkPermissions = true
|
||||
default:
|
||||
return ListPermissionsQueryOptions{}, errorcode.New(errorcode.InvalidRequest, "invalid filter value")
|
||||
}
|
||||
}
|
||||
|
||||
selectAttrs, err := odata.GetSelectValues(odataReq.Query)
|
||||
if err != nil {
|
||||
return ListPermissionsQueryOptions{}, err
|
||||
}
|
||||
|
||||
queryOptions.SelectedAttrs = selectAttrs
|
||||
if odataReq.Query.Count != nil {
|
||||
queryOptions.Count = bool(*odataReq.Query.Count)
|
||||
}
|
||||
if odataReq.Query.Top != nil {
|
||||
top := int(*odataReq.Query.Top)
|
||||
switch {
|
||||
case top != 0:
|
||||
return ListPermissionsQueryOptions{}, err
|
||||
case top == 0 && !queryOptions.Count:
|
||||
return ListPermissionsQueryOptions{}, err
|
||||
default:
|
||||
queryOptions.NoValues = true
|
||||
}
|
||||
}
|
||||
return queryOptions, nil
|
||||
}
|
||||
|
||||
// DeletePermission handles DeletePermission requests
|
||||
func (api DriveItemPermissionsApi) DeletePermission(w http.ResponseWriter, r *http.Request) {
|
||||
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(invalidIdMsg)
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg("could not parse permissionID")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
err = api.driveItemPermissionsService.DeletePermission(ctx, itemID, permissionID)
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// DeleteSpaceRootPermission handles DeletePermission requests on a space root
|
||||
func (api DriveItemPermissionsApi) DeleteSpaceRootPermission(w http.ResponseWriter, r *http.Request) {
|
||||
driveID, err := parseIDParam(r, "driveID")
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(parseDriveIDErrMsg)
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, parseDriveIDErrMsg)
|
||||
return
|
||||
}
|
||||
|
||||
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg("could not parse permissionID")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
err = api.driveItemPermissionsService.DeleteSpaceRootPermission(ctx, &driveID, permissionID)
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// UpdatePermission handles UpdatePermission requests
|
||||
func (api DriveItemPermissionsApi) UpdatePermission(w http.ResponseWriter, r *http.Request) {
|
||||
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(invalidIdMsg)
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg("could not parse permissionID")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
|
||||
return
|
||||
}
|
||||
|
||||
permission := libregraph.Permission{}
|
||||
if err = StrictJSONUnmarshal(r.Body, &permission); err != nil {
|
||||
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if err = validate.StructCtx(ctx, permission); err != nil {
|
||||
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updatedPermission, err := api.driveItemPermissionsService.UpdatePermission(ctx, itemID, permissionID, permission)
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &updatedPermission)
|
||||
}
|
||||
|
||||
// UpdateSpaceRootPermission handles UpdatePermission requests on a space root
|
||||
func (api DriveItemPermissionsApi) UpdateSpaceRootPermission(w http.ResponseWriter, r *http.Request) {
|
||||
driveID, err := parseIDParam(r, "driveID")
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(parseDriveIDErrMsg)
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, parseDriveIDErrMsg)
|
||||
return
|
||||
}
|
||||
|
||||
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg("could not parse permissionID")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
|
||||
return
|
||||
}
|
||||
|
||||
permission := libregraph.Permission{}
|
||||
if err = StrictJSONUnmarshal(r.Body, &permission); err != nil {
|
||||
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if err = validate.StructCtx(ctx, permission); err != nil {
|
||||
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updatedPermission, err := api.driveItemPermissionsService.UpdateSpaceRootPermission(ctx, &driveID, permissionID, permission)
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &updatedPermission)
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
"github.com/qsfera/server/services/graph/pkg/linktype"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
func (s DriveItemPermissionsService) CreateLink(ctx context.Context, driveItemID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.GeneralException, err.Error())
|
||||
}
|
||||
|
||||
statResp, err := gatewayClient.Stat(
|
||||
ctx,
|
||||
&storageprovider.StatRequest{
|
||||
Ref: &storageprovider.Reference{
|
||||
ResourceId: driveItemID,
|
||||
Path: ".",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("transport error, could not stat resource")
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.GeneralException, err.Error())
|
||||
}
|
||||
if code := statResp.GetStatus().GetCode(); code != rpc.Code_CODE_OK {
|
||||
s.logger.Debug().Interface("itemID", driveItemID).Msg(statResp.GetStatus().GetMessage())
|
||||
return libregraph.Permission{}, errorcode.New(cs3StatusToErrCode(code), statResp.GetStatus().GetMessage())
|
||||
}
|
||||
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(createLink, statResp.GetInfo().GetType())
|
||||
if err != nil {
|
||||
s.logger.Debug().Interface("createLink", createLink).Msg(err.Error())
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "invalid link type")
|
||||
}
|
||||
if createLink.GetType() == libregraph.INTERNAL && len(createLink.GetPassword()) > 0 {
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "password is redundant for the internal link")
|
||||
}
|
||||
req := link.CreatePublicShareRequest{
|
||||
ResourceInfo: statResp.GetInfo(),
|
||||
Grant: &link.Grant{
|
||||
Permissions: &link.PublicSharePermissions{
|
||||
Permissions: permissions,
|
||||
},
|
||||
Password: createLink.GetPassword(),
|
||||
},
|
||||
}
|
||||
expirationDate, isSet := createLink.GetExpirationDateTimeOk()
|
||||
if isSet {
|
||||
expireTime := parseAndFillUpTime(expirationDate)
|
||||
if expireTime == nil {
|
||||
s.logger.Debug().Interface("createLink", createLink).Send()
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "invalid expiration date")
|
||||
}
|
||||
req.GetGrant().Expiration = expireTime
|
||||
}
|
||||
|
||||
// set displayname and password protected as arbitrary metadata
|
||||
req.ResourceInfo.ArbitraryMetadata = &storageprovider.ArbitraryMetadata{
|
||||
Metadata: map[string]string{
|
||||
"name": createLink.GetDisplayName(),
|
||||
"quicklink": strconv.FormatBool(createLink.GetLibreGraphQuickLink()),
|
||||
},
|
||||
}
|
||||
createResp, err := gatewayClient.CreatePublicShare(ctx, &req)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("transport error, could not create link")
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.GeneralException, err.Error())
|
||||
}
|
||||
if statusCode := createResp.GetStatus().GetCode(); statusCode != rpc.Code_CODE_OK {
|
||||
return libregraph.Permission{}, errorcode.New(cs3StatusToErrCode(statusCode), createResp.Status.Message)
|
||||
}
|
||||
link := createResp.GetShare()
|
||||
perm, err := s.libreGraphPermissionFromCS3PublicShare(link)
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.GeneralException, err.Error())
|
||||
}
|
||||
return *perm, nil
|
||||
}
|
||||
|
||||
func (s DriveItemPermissionsService) CreateSpaceRootLink(ctx context.Context, driveID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, errorcode.FromUtilsStatusCodeError(err)
|
||||
}
|
||||
|
||||
if space.SpaceType != _spaceTypeProject {
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
|
||||
}
|
||||
|
||||
rootResourceID := space.GetRoot()
|
||||
return s.CreateLink(ctx, rootResourceID, createLink)
|
||||
}
|
||||
|
||||
func (s DriveItemPermissionsService) SetPublicLinkPassword(ctx context.Context, driveItemId *storageprovider.ResourceId, permissionID string, password string) (libregraph.Permission, error) {
|
||||
publicShare, err := s.getCS3PublicShareByID(ctx, permissionID)
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
|
||||
// The resourceID of the shared resource need to match the item ID from the Request Path
|
||||
// otherwise this is an invalid Request.
|
||||
if !utils.ResourceIDEqual(publicShare.GetResourceId(), driveItemId) {
|
||||
s.logger.Debug().Msg("resourceID of shared does not match itemID")
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "permissionID and itemID do not match")
|
||||
}
|
||||
|
||||
permission, err := s.updatePublicLinkPassword(ctx, permissionID, password)
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
return *permission, nil
|
||||
}
|
||||
|
||||
func (s DriveItemPermissionsService) SetPublicLinkPasswordOnSpaceRoot(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string, password string) (libregraph.Permission, error) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, err
|
||||
}
|
||||
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
|
||||
if err != nil {
|
||||
return libregraph.Permission{}, errorcode.FromUtilsStatusCodeError(err)
|
||||
}
|
||||
|
||||
if space.SpaceType != _spaceTypeProject {
|
||||
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
|
||||
}
|
||||
rootResourceID := space.GetRoot()
|
||||
return s.SetPublicLinkPassword(ctx, rootResourceID, permissionID, password)
|
||||
}
|
||||
|
||||
// CreateLink creates a public link on the cs3 api
|
||||
func (api DriveItemPermissionsApi) CreateLink(w http.ResponseWriter, r *http.Request) {
|
||||
logger := api.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Msg("calling create link")
|
||||
|
||||
_, driveItemID, err := GetDriveAndItemIDParam(r, &logger)
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var createLink libregraph.DriveItemCreateLink
|
||||
if err = StrictJSONUnmarshal(r.Body, &createLink); err != nil {
|
||||
logger.Error().Err(err).Interface("body", r.Body).Msg("could not create link: invalid body schema definition")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition")
|
||||
return
|
||||
}
|
||||
|
||||
perm, err := api.driveItemPermissionsService.CreateLink(r.Context(), driveItemID, createLink)
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, perm)
|
||||
}
|
||||
|
||||
func (api DriveItemPermissionsApi) CreateSpaceRootLink(w http.ResponseWriter, r *http.Request) {
|
||||
logger := api.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Msg("calling create link")
|
||||
|
||||
driveID, err := parseIDParam(r, "driveID")
|
||||
if err != nil {
|
||||
msg := "could not parse driveID"
|
||||
api.logger.Debug().Err(err).Msg(msg)
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, msg)
|
||||
return
|
||||
}
|
||||
|
||||
var createLink libregraph.DriveItemCreateLink
|
||||
if err = StrictJSONUnmarshal(r.Body, &createLink); err != nil {
|
||||
logger.Error().Err(err).Interface("body", r.Body).Msg("could not create link: invalid body schema definition")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition")
|
||||
return
|
||||
}
|
||||
|
||||
perm, err := api.driveItemPermissionsService.CreateSpaceRootLink(r.Context(), &driveID, createLink)
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, perm)
|
||||
}
|
||||
|
||||
// SetLinkPassword sets public link password on the cs3 api
|
||||
func (api DriveItemPermissionsApi) SetLinkPassword(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg("could not parse permissionID")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
|
||||
return
|
||||
}
|
||||
|
||||
password := &libregraph.SharingLinkPassword{}
|
||||
if err = StrictJSONUnmarshal(r.Body, password); err != nil {
|
||||
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
newPermission, err := api.driveItemPermissionsService.SetPublicLinkPassword(ctx, itemID, permissionID, password.GetPassword())
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, newPermission)
|
||||
}
|
||||
|
||||
func (api DriveItemPermissionsApi) SetSpaceRootLinkPassword(w http.ResponseWriter, r *http.Request) {
|
||||
driveID, err := parseIDParam(r, "driveID")
|
||||
if err != nil {
|
||||
msg := "could not parse driveID"
|
||||
api.logger.Debug().Err(err).Msg(msg)
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, msg)
|
||||
return
|
||||
}
|
||||
|
||||
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg("could not parse permissionID")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
|
||||
return
|
||||
}
|
||||
|
||||
password := &libregraph.SharingLinkPassword{}
|
||||
if err = StrictJSONUnmarshal(r.Body, password); err != nil {
|
||||
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
newPermission, err := api.driveItemPermissionsService.SetPublicLinkPasswordOnSpaceRoot(ctx, &driveID, permissionID, password.GetPassword())
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, newPermission)
|
||||
}
|
||||
|
||||
func (s DriveItemPermissionsService) updatePublicLinkPermission(ctx context.Context, permissionID string, itemID *storageprovider.ResourceId, newPermission *libregraph.Permission) (perm *libregraph.Permission, err error) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
return nil, errorcode.New(errorcode.GeneralException, err.Error())
|
||||
}
|
||||
|
||||
statResp, err := gatewayClient.Stat(
|
||||
ctx,
|
||||
&storageprovider.StatRequest{
|
||||
Ref: &storageprovider.Reference{
|
||||
ResourceId: itemID,
|
||||
Path: ".",
|
||||
},
|
||||
})
|
||||
|
||||
if err := errorcode.FromCS3Status(statResp.GetStatus(), err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if newPermission.HasExpirationDateTime() {
|
||||
expirationDate := newPermission.GetExpirationDateTime()
|
||||
update := &link.UpdatePublicShareRequest_Update{
|
||||
Type: link.UpdatePublicShareRequest_Update_TYPE_EXPIRATION,
|
||||
Grant: &link.Grant{Expiration: parseAndFillUpTime(&expirationDate)},
|
||||
}
|
||||
perm, err = s.updatePublicLink(ctx, permissionID, update)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if newPermission.HasLink() && newPermission.Link.HasLibreGraphDisplayName() {
|
||||
changedLink := newPermission.GetLink()
|
||||
update := &link.UpdatePublicShareRequest_Update{
|
||||
Type: link.UpdatePublicShareRequest_Update_TYPE_DISPLAYNAME,
|
||||
DisplayName: changedLink.GetLibreGraphDisplayName(),
|
||||
}
|
||||
perm, err = s.updatePublicLink(ctx, permissionID, update)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if newPermission.HasLink() && newPermission.Link.HasType() {
|
||||
changedLink := newPermission.Link.GetType()
|
||||
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(
|
||||
libregraph.DriveItemCreateLink{
|
||||
Type: &changedLink,
|
||||
},
|
||||
statResp.GetInfo().GetType(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
update := &link.UpdatePublicShareRequest_Update{
|
||||
Type: link.UpdatePublicShareRequest_Update_TYPE_PERMISSIONS,
|
||||
Grant: &link.Grant{
|
||||
Permissions: &link.PublicSharePermissions{Permissions: permissions},
|
||||
},
|
||||
}
|
||||
perm, err = s.updatePublicLink(ctx, permissionID, update)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// reset the password for the internal link
|
||||
if changedLink == libregraph.INTERNAL {
|
||||
perm, err = s.updatePublicLinkPassword(ctx, permissionID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return perm, err
|
||||
}
|
||||
|
||||
func (s DriveItemPermissionsService) updatePublicLinkPassword(ctx context.Context, permissionID string, password string) (*libregraph.Permission, error) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
changeLinkRes, err := gatewayClient.UpdatePublicShare(ctx, &link.UpdatePublicShareRequest{
|
||||
Update: &link.UpdatePublicShareRequest_Update{
|
||||
Type: link.UpdatePublicShareRequest_Update_TYPE_PASSWORD,
|
||||
Grant: &link.Grant{
|
||||
Password: password,
|
||||
},
|
||||
},
|
||||
Ref: &link.PublicShareReference{
|
||||
Spec: &link.PublicShareReference_Id{
|
||||
Id: &link.PublicShareId{
|
||||
OpaqueId: permissionID,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := errorcode.FromCS3Status(changeLinkRes.GetStatus(), err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
permission, err := s.libreGraphPermissionFromCS3PublicShare(changeLinkRes.GetShare())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return permission, nil
|
||||
}
|
||||
|
||||
func (s DriveItemPermissionsService) updatePublicLink(ctx context.Context, permissionID string, update *link.UpdatePublicShareRequest_Update) (*libregraph.Permission, error) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
changeLinkRes, err := gatewayClient.UpdatePublicShare(ctx, &link.UpdatePublicShareRequest{
|
||||
Update: update,
|
||||
Ref: &link.PublicShareReference{
|
||||
Spec: &link.PublicShareReference_Id{
|
||||
Id: &link.PublicShareId{
|
||||
OpaqueId: permissionID,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := errorcode.FromCS3Status(changeLinkRes.GetStatus(), err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
permission, err := s.libreGraphPermissionFromCS3PublicShare(changeLinkRes.GetShare())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return permission, nil
|
||||
}
|
||||
|
||||
func parseAndFillUpTime(t *time.Time) *types.Timestamp {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// the link needs to be valid for the whole day
|
||||
tLink := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
tLink = tLink.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
||||
|
||||
final := tLink.UnixNano()
|
||||
|
||||
return &types.Timestamp{
|
||||
Seconds: uint64(final / 1000000000),
|
||||
Nanos: uint32(final % 1000000000),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/graph/mocks"
|
||||
"github.com/qsfera/server/services/graph/pkg/config/defaults"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
"github.com/qsfera/server/services/graph/pkg/identity/cache"
|
||||
"github.com/qsfera/server/services/graph/pkg/linktype"
|
||||
service "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("createLinkTests", func() {
|
||||
var (
|
||||
svc service.DriveItemPermissionsService
|
||||
driveItemId *provider.ResourceId
|
||||
ctx context.Context
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector *mocks.Selectable[gateway.GatewayAPIClient]
|
||||
currentUser = &userpb.User{
|
||||
Id: &userpb.UserId{
|
||||
OpaqueId: "user",
|
||||
},
|
||||
}
|
||||
)
|
||||
const (
|
||||
ViewerLinkString = "Viewer Link"
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
logger := log.NewLogger()
|
||||
gatewayClient = cs3mocks.NewGatewayAPIClient(GinkgoT())
|
||||
|
||||
gatewaySelector = mocks.NewSelectable[gateway.GatewayAPIClient](GinkgoT())
|
||||
gatewaySelector.On("Next").Return(gatewayClient, nil)
|
||||
|
||||
cache := cache.NewIdentityCache(cache.IdentityCacheWithGatewaySelector(gatewaySelector))
|
||||
|
||||
cfg := defaults.FullDefaultConfig()
|
||||
svc, err = service.NewDriveItemPermissionsService(logger, gatewaySelector, cache, cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
driveItemId = &provider.ResourceId{
|
||||
StorageId: "1",
|
||||
SpaceId: "2",
|
||||
OpaqueId: "3",
|
||||
}
|
||||
ctx = revactx.ContextSetUser(context.Background(), currentUser)
|
||||
})
|
||||
|
||||
Describe("CreateLink", func() {
|
||||
var (
|
||||
driveItemCreateLink libregraph.DriveItemCreateLink
|
||||
statResponse *provider.StatResponse
|
||||
createLinkResponse *link.CreatePublicShareResponse
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
driveItemCreateLink = libregraph.DriveItemCreateLink{
|
||||
Type: nil,
|
||||
ExpirationDateTime: nil,
|
||||
Password: nil,
|
||||
DisplayName: nil,
|
||||
LibreGraphQuickLink: nil,
|
||||
}
|
||||
|
||||
statResponse = &provider.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &provider.ResourceInfo{Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER},
|
||||
}
|
||||
|
||||
createLinkResponse = &link.CreatePublicShareResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
}
|
||||
|
||||
linkType, err := libregraph.NewSharingLinkTypeFromValue("view")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
driveItemCreateLink.Type = linkType
|
||||
driveItemCreateLink.ExpirationDateTime = libregraph.PtrTime(time.Now().Add(time.Hour))
|
||||
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(driveItemCreateLink, provider.ResourceType_RESOURCE_TYPE_CONTAINER)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
createLinkResponse.Share = &link.PublicShare{
|
||||
Id: &link.PublicShareId{OpaqueId: "123"},
|
||||
Expiration: utils.TimeToTS(*driveItemCreateLink.ExpirationDateTime),
|
||||
PasswordProtected: false,
|
||||
DisplayName: ViewerLinkString,
|
||||
Token: "SomeGOODCoffee",
|
||||
Permissions: &link.PublicSharePermissions{Permissions: permissions},
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// Public Shares / "links" in graph terms
|
||||
It("creates a public link as expected (happy path)", func() {
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
|
||||
gatewayClient.On("CreatePublicShare", mock.Anything, mock.Anything).Return(createLinkResponse, nil)
|
||||
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(perm.GetId()).To(Equal("123"))
|
||||
Expect(perm.GetExpirationDateTime().Unix()).To(Equal(driveItemCreateLink.ExpirationDateTime.Unix()))
|
||||
Expect(perm.GetHasPassword()).To(Equal(false))
|
||||
Expect(perm.GetLink().LibreGraphDisplayName).To(Equal(libregraph.PtrString(ViewerLinkString)))
|
||||
link := perm.GetLink()
|
||||
respLinkType := link.GetType()
|
||||
expected, err := libregraph.NewSharingLinkTypeFromValue("view")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(&respLinkType).To(Equal(expected))
|
||||
})
|
||||
|
||||
It("handles a failing CreateLink", func() {
|
||||
statResponse.Info = &provider.ResourceInfo{Type: provider.ResourceType_RESOURCE_TYPE_FILE}
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
|
||||
gatewayClient.On("CreatePublicShare", mock.Anything, mock.Anything).Return(createLinkResponse, nil)
|
||||
|
||||
linkType, err := libregraph.NewSharingLinkTypeFromValue("edit")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
driveItemCreateLink.Type = linkType
|
||||
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(driveItemCreateLink, provider.ResourceType_RESOURCE_TYPE_CONTAINER)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
createLinkResponse.Status = status.NewInternal(ctx, "transport error")
|
||||
createLinkResponse.Share = &link.PublicShare{
|
||||
Id: &link.PublicShareId{OpaqueId: "123"},
|
||||
Permissions: &link.PublicSharePermissions{Permissions: permissions},
|
||||
}
|
||||
|
||||
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
|
||||
Expect(err).To(MatchError(errorcode.New(errorcode.GeneralException, "transport error")))
|
||||
Expect(perm).To(BeZero())
|
||||
})
|
||||
|
||||
It("fails when the stat returns access denied", func() {
|
||||
err := errors.New("no permission to stat the file")
|
||||
statResponse.Status = status.NewPermissionDenied(ctx, err, err.Error())
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
|
||||
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
|
||||
Expect(err).To(MatchError(errorcode.New(errorcode.AccessDenied, "no permission to stat the file")))
|
||||
Expect(perm).To(BeZero())
|
||||
})
|
||||
|
||||
It("fails when the stat returns resource is locked", func() {
|
||||
err := errors.New("the resource is locked")
|
||||
statResponse.Status = status.NewLocked(ctx, err.Error())
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
|
||||
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
|
||||
Expect(err).To(MatchError(errorcode.New(errorcode.ItemIsLocked, "the resource is locked")))
|
||||
Expect(perm).To(BeZero())
|
||||
})
|
||||
|
||||
It("succeeds when the link type mapping is not successful", func() {
|
||||
// we need to send a valid link type
|
||||
linkType, err := libregraph.NewSharingLinkTypeFromValue("edit")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
driveItemCreateLink.Type = linkType
|
||||
permissions := &provider.ResourcePermissions{
|
||||
CreateContainer: true,
|
||||
InitiateFileUpload: true,
|
||||
Move: true,
|
||||
}
|
||||
// return different permissions which do not match a link type
|
||||
createLinkResponse.Share = &link.PublicShare{
|
||||
Id: &link.PublicShareId{OpaqueId: "123"},
|
||||
Expiration: utils.TimeToTS(*driveItemCreateLink.ExpirationDateTime),
|
||||
PasswordProtected: false,
|
||||
DisplayName: ViewerLinkString,
|
||||
Token: "SomeGOODCoffee",
|
||||
Permissions: &link.PublicSharePermissions{Permissions: permissions},
|
||||
}
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
|
||||
gatewayClient.On("CreatePublicShare", mock.Anything, mock.Anything).Return(createLinkResponse, nil)
|
||||
|
||||
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(perm.GetId()).To(Equal("123"))
|
||||
Expect(perm.GetExpirationDateTime().Unix()).To(Equal(driveItemCreateLink.ExpirationDateTime.Unix()))
|
||||
Expect(perm.GetHasPassword()).To(Equal(false))
|
||||
Expect(perm.GetLink().LibreGraphDisplayName).To(Equal(libregraph.PtrString(ViewerLinkString)))
|
||||
respLink := perm.GetLink()
|
||||
// some conversion gymnastics
|
||||
respLinkType := respLink.GetType()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
mockLink := libregraph.SharingLink{}
|
||||
lt, _ := linktype.SharingLinkTypeFromCS3Permissions(&link.PublicSharePermissions{Permissions: permissions})
|
||||
mockLink.Type = lt
|
||||
expectedType := mockLink.GetType()
|
||||
Expect(&respLinkType).To(Equal(&expectedType))
|
||||
libreGraphActions := perm.LibreGraphPermissionsActions
|
||||
Expect(libreGraphActions[0]).To(Equal("libre.graph/driveItem/children/create"))
|
||||
Expect(libreGraphActions[1]).To(Equal("libre.graph/driveItem/upload/create"))
|
||||
Expect(libreGraphActions[2]).To(Equal("libre.graph/driveItem/path/update"))
|
||||
})
|
||||
})
|
||||
Describe("SetLinPassword", func() {
|
||||
var (
|
||||
updatePublicShareMockResponse link.UpdatePublicShareResponse
|
||||
getPublicShareResponse link.GetPublicShareResponse
|
||||
)
|
||||
|
||||
const TestLinkName = "Test Link"
|
||||
|
||||
BeforeEach(func() {
|
||||
updatePublicShareMockResponse = link.UpdatePublicShareResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Share: &link.PublicShare{DisplayName: TestLinkName},
|
||||
}
|
||||
getPublicShareResponse = link.GetPublicShareResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Share: &link.PublicShare{
|
||||
Id: &link.PublicShareId{
|
||||
OpaqueId: "permissionid",
|
||||
},
|
||||
ResourceId: driveItemId,
|
||||
Permissions: &link.PublicSharePermissions{
|
||||
Permissions: linktype.NewViewLinkPermissionSet().GetPermissions(),
|
||||
},
|
||||
Token: "token",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("updates the password on a public share", func() {
|
||||
gatewayClient.On("GetPublicShare", mock.Anything, mock.Anything).Return(&getPublicShareResponse, nil)
|
||||
|
||||
updatePublicShareMockResponse.Share.Permissions = &link.PublicSharePermissions{
|
||||
Permissions: linktype.NewViewLinkPermissionSet().Permissions,
|
||||
}
|
||||
updatePublicShareMockResponse.Share.PasswordProtected = true
|
||||
gatewayClient.On("UpdatePublicShare",
|
||||
mock.Anything,
|
||||
mock.MatchedBy(func(req *link.UpdatePublicShareRequest) bool {
|
||||
return req.GetRef().GetId().GetOpaqueId() == "permissionid"
|
||||
}),
|
||||
).Return(&updatePublicShareMockResponse, nil)
|
||||
|
||||
perm, err := svc.SetPublicLinkPassword(context.Background(), driveItemId, "permissionid", "OC123!")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
linkType := perm.Link.GetType()
|
||||
Expect(string(linkType)).To(Equal("view"))
|
||||
Expect(perm.GetHasPassword()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,575 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
)
|
||||
|
||||
const (
|
||||
_fieldMaskPathState = "state"
|
||||
_fieldMaskPathMountPoint = "mount_point"
|
||||
_fieldMaskPathHidden = "hidden"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoUpdates is returned when no updates are provided
|
||||
ErrNoUpdates = errors.New("no updates")
|
||||
|
||||
// ErrNoUpdater is returned when no updater is provided
|
||||
ErrNoUpdater = errors.New("no updater")
|
||||
|
||||
// ErrAbsoluteNamePath is returned when the name is an absolute path
|
||||
ErrAbsoluteNamePath = errors.New("name cannot be an absolute path")
|
||||
|
||||
// ErrCode errors
|
||||
|
||||
// ErrNotAShareJail is returned when the driveID does not belong to a share jail
|
||||
ErrNotAShareJail = errorcode.New(errorcode.InvalidRequest, "id does not belong to a share jail")
|
||||
|
||||
// ErrInvalidDriveIDOrItemID is returned when the driveID or itemID is invalid
|
||||
ErrInvalidDriveIDOrItemID = errorcode.New(errorcode.InvalidRequest, "invalid driveID or itemID")
|
||||
|
||||
// ErrInvalidRequestBody is returned when the request body is invalid
|
||||
ErrInvalidRequestBody = errorcode.New(errorcode.InvalidRequest, "invalid request body")
|
||||
|
||||
// ErrUnmountShare is returned when unmounting a share fails
|
||||
ErrUnmountShare = errorcode.New(errorcode.InvalidRequest, "unmounting share failed")
|
||||
|
||||
// ErrMountShare is returned when mounting a share fails
|
||||
ErrMountShare = errorcode.New(errorcode.InvalidRequest, "mounting share failed")
|
||||
|
||||
// ErrUpdateShares is returned when updating shares fails
|
||||
ErrUpdateShares = errorcode.New(errorcode.InvalidRequest, "failed to update share")
|
||||
|
||||
// ErrInvalidID is returned when the id is invalid
|
||||
ErrInvalidID = errorcode.New(errorcode.InvalidRequest, "invalid id")
|
||||
|
||||
// ErrDriveItemConversion is returned when converting to drive items fails
|
||||
ErrDriveItemConversion = errorcode.New(errorcode.InvalidRequest, "converting to drive items failed")
|
||||
|
||||
// ErrNoShares is returned when no shares are found
|
||||
ErrNoShares = errorcode.New(errorcode.ItemNotFound, "no shares found")
|
||||
|
||||
// ErrAlreadyMounted is returned when all shares are already mounted
|
||||
ErrAlreadyMounted = errorcode.New(errorcode.NameAlreadyExists, "shares already mounted")
|
||||
|
||||
// ErrAlreadyUnmounted is returned when all shares are already unmounted
|
||||
ErrAlreadyUnmounted = errorcode.New(errorcode.NameAlreadyExists, "shares already unmounted")
|
||||
)
|
||||
|
||||
type (
|
||||
// UpdateShareClosure is a closure that injects required updates into the update request
|
||||
UpdateShareClosure func(share *collaboration.ReceivedShare, request *collaboration.UpdateReceivedShareRequest)
|
||||
|
||||
// DrivesDriveItemProvider is the interface that needs to be implemented by the individual space service
|
||||
DrivesDriveItemProvider interface {
|
||||
// MountShare mounts a share
|
||||
MountShare(ctx context.Context, resourceID *storageprovider.ResourceId, name string) ([]*collaboration.ReceivedShare, error)
|
||||
|
||||
// MountOCMShare mounts an OCM share
|
||||
MountOCMShare(ctx context.Context, resourceID *storageprovider.ResourceId /*, name string*/) ([]*ocm.ReceivedShare, error)
|
||||
|
||||
// UnmountShare unmounts a share
|
||||
UnmountShare(ctx context.Context, shareID *collaboration.ShareId) error
|
||||
|
||||
// UpdateShares updates multiple shares
|
||||
UpdateShares(ctx context.Context, shares []*collaboration.ReceivedShare, updater UpdateShareClosure) ([]*collaboration.ReceivedShare, error)
|
||||
|
||||
// GetShare returns the share
|
||||
GetShare(ctx context.Context, shareID *collaboration.ShareId) (*collaboration.ReceivedShare, error)
|
||||
|
||||
// GetSharesForResource returns all shares for a given resourceID
|
||||
GetSharesForResource(ctx context.Context, resourceID *storageprovider.ResourceId, filters []*collaboration.Filter) ([]*collaboration.ReceivedShare, error)
|
||||
}
|
||||
)
|
||||
|
||||
// DrivesDriveItemService contains the production business logic for everything that relates to drives
|
||||
type DrivesDriveItemService struct {
|
||||
logger log.Logger
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
}
|
||||
|
||||
// NewDrivesDriveItemService creates a new DrivesDriveItemService
|
||||
func NewDrivesDriveItemService(logger log.Logger, gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) (DrivesDriveItemService, error) {
|
||||
return DrivesDriveItemService{
|
||||
logger: log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemService").Logger()},
|
||||
gatewaySelector: gatewaySelector,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s DrivesDriveItemService) GetShare(ctx context.Context, shareID *collaboration.ShareId) (*collaboration.ReceivedShare, error) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Now, find out the resourceID of the shared resource
|
||||
getReceivedShareResponse, err := gatewayClient.GetReceivedShare(ctx,
|
||||
&collaboration.GetReceivedShareRequest{
|
||||
Ref: &collaboration.ShareReference{
|
||||
Spec: &collaboration.ShareReference_Id{
|
||||
Id: shareID,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return getReceivedShareResponse.GetShare(), errorcode.FromCS3Status(getReceivedShareResponse.GetStatus(), err)
|
||||
}
|
||||
|
||||
// GetSharesForResource returns all shares for a given resourceID
|
||||
func (s DrivesDriveItemService) GetSharesForResource(ctx context.Context, resourceID *storageprovider.ResourceId, filters []*collaboration.Filter) ([]*collaboration.ReceivedShare, error) {
|
||||
// Find all accepted shares for this resource
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
receivedSharesResponse, err := gatewayClient.ListReceivedShares(ctx, &collaboration.ListReceivedSharesRequest{
|
||||
Filters: append([]*collaboration.Filter{
|
||||
{
|
||||
Type: collaboration.Filter_TYPE_RESOURCE_ID,
|
||||
Term: &collaboration.Filter_ResourceId{
|
||||
ResourceId: resourceID,
|
||||
},
|
||||
},
|
||||
}, filters...),
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, err
|
||||
case len(receivedSharesResponse.GetShares()) == 0:
|
||||
return nil, ErrNoShares
|
||||
default:
|
||||
return receivedSharesResponse.GetShares(), errorcode.FromCS3Status(receivedSharesResponse.GetStatus(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateShares updates multiple shares;
|
||||
// it could happen that some shares are updated and some are not,
|
||||
// this will return a list of updated shares and a list of errors;
|
||||
// there is no guarantee that all updates are successful
|
||||
func (s DrivesDriveItemService) UpdateShares(ctx context.Context, shares []*collaboration.ReceivedShare, updater UpdateShareClosure) ([]*collaboration.ReceivedShare, error) {
|
||||
errs := make([]error, 0, len(shares))
|
||||
updatedShares := make([]*collaboration.ReceivedShare, 0, len(shares))
|
||||
|
||||
for _, share := range shares {
|
||||
updatedShare, err := s.UpdateShare(
|
||||
ctx,
|
||||
share,
|
||||
updater,
|
||||
)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
|
||||
updatedShares = append(updatedShares, updatedShare)
|
||||
}
|
||||
|
||||
return updatedShares, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// UpdateShare updates a single share
|
||||
func (s DrivesDriveItemService) UpdateShare(ctx context.Context, share *collaboration.ReceivedShare, updater UpdateShareClosure) (*collaboration.ReceivedShare, error) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updateReceivedShareRequest := &collaboration.UpdateReceivedShareRequest{
|
||||
Share: &collaboration.ReceivedShare{
|
||||
Share: &collaboration.Share{
|
||||
Id: share.GetShare().GetId(),
|
||||
},
|
||||
},
|
||||
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{}},
|
||||
}
|
||||
|
||||
switch updater {
|
||||
case nil:
|
||||
return nil, ErrNoUpdater
|
||||
default:
|
||||
updater(share, updateReceivedShareRequest)
|
||||
}
|
||||
|
||||
if len(updateReceivedShareRequest.GetUpdateMask().GetPaths()) == 0 {
|
||||
return nil, ErrNoUpdates
|
||||
}
|
||||
|
||||
updateReceivedShareResponse, err := gatewayClient.UpdateReceivedShare(ctx, updateReceivedShareRequest)
|
||||
return updateReceivedShareResponse.GetShare(), errorcode.FromCS3Status(updateReceivedShareResponse.GetStatus(), err)
|
||||
}
|
||||
|
||||
// UnmountShare unmounts a share
|
||||
func (s DrivesDriveItemService) UnmountShare(ctx context.Context, shareID *collaboration.ShareId) error {
|
||||
share, err := s.GetShare(ctx, shareID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
shares, err := s.GetSharesForResource(ctx, share.GetShare().GetResourceId(), []*collaboration.Filter{
|
||||
{
|
||||
Type: collaboration.Filter_TYPE_STATE,
|
||||
Term: &collaboration.Filter_State{
|
||||
State: collaboration.ShareState_SHARE_STATE_ACCEPTED,
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: collaboration.Filter_TYPE_STATE,
|
||||
Term: &collaboration.Filter_State{
|
||||
State: collaboration.ShareState_SHARE_STATE_REJECTED,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
availableShares := make([]*collaboration.ReceivedShare, 0, 1)
|
||||
rejectedShares := make([]*collaboration.ReceivedShare, 0, 1)
|
||||
for _, v := range shares {
|
||||
switch v.GetState() {
|
||||
case collaboration.ShareState_SHARE_STATE_ACCEPTED:
|
||||
availableShares = append(availableShares, v)
|
||||
case collaboration.ShareState_SHARE_STATE_REJECTED:
|
||||
rejectedShares = append(rejectedShares, v)
|
||||
}
|
||||
}
|
||||
if len(availableShares) == 0 {
|
||||
if len(rejectedShares) > 0 {
|
||||
return ErrAlreadyUnmounted
|
||||
}
|
||||
return ErrNoShares
|
||||
}
|
||||
|
||||
_, err = s.UpdateShares(ctx, availableShares, func(_ *collaboration.ReceivedShare, request *collaboration.UpdateReceivedShareRequest) {
|
||||
request.Share.State = collaboration.ShareState_SHARE_STATE_REJECTED
|
||||
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathState)
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// MountShare mounts a share, there is no guarantee that all siblings will be mounted
|
||||
// in some rare cases it could happen that none of the siblings could be mounted,
|
||||
// then the error will be returned
|
||||
func (s DrivesDriveItemService) MountShare(ctx context.Context, resourceID *storageprovider.ResourceId, name string) ([]*collaboration.ReceivedShare, error) {
|
||||
if filepath.IsAbs(name) {
|
||||
return nil, ErrAbsoluteNamePath
|
||||
}
|
||||
|
||||
if name != "" {
|
||||
name = filepath.Clean(name)
|
||||
}
|
||||
|
||||
shares, err := s.GetSharesForResource(ctx, resourceID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
availableShares := make([]*collaboration.ReceivedShare, 0, len(shares))
|
||||
mountedShares := make([]*collaboration.ReceivedShare, 0, 1)
|
||||
for _, v := range shares {
|
||||
switch v.GetState() {
|
||||
case collaboration.ShareState_SHARE_STATE_ACCEPTED:
|
||||
mountedShares = append(mountedShares, v)
|
||||
case collaboration.ShareState_SHARE_STATE_PENDING, collaboration.ShareState_SHARE_STATE_REJECTED:
|
||||
availableShares = append(availableShares, v)
|
||||
}
|
||||
}
|
||||
if len(availableShares) == 0 {
|
||||
if len(mountedShares) > 0 {
|
||||
return nil, ErrAlreadyMounted
|
||||
}
|
||||
return nil, ErrNoShares
|
||||
}
|
||||
|
||||
updatedShares, err := s.UpdateShares(ctx, availableShares, func(share *collaboration.ReceivedShare, request *collaboration.UpdateReceivedShareRequest) {
|
||||
request.Share.State = collaboration.ShareState_SHARE_STATE_ACCEPTED
|
||||
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathState)
|
||||
|
||||
// only update if mountPoint name is not empty and the path has changed
|
||||
if name != "" {
|
||||
mountPoint := share.GetMountPoint()
|
||||
if mountPoint == nil {
|
||||
mountPoint = &storageprovider.Reference{}
|
||||
}
|
||||
|
||||
if filepath.Clean(mountPoint.GetPath()) != name {
|
||||
mountPoint.Path = name
|
||||
request.Share.MountPoint = mountPoint
|
||||
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathMountPoint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
errs, ok := err.(interface{ Unwrap() []error })
|
||||
if ok && len(errs.Unwrap()) == len(availableShares) {
|
||||
// none of the received shares could be accepted.
|
||||
// this is an error, return it.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedShares, nil
|
||||
}
|
||||
|
||||
// DrivesDriveItemApi is the api that registers the http endpoints which expose needed operation to the graph api.
|
||||
// the business logic is delegated to the space service and further down to the cs3 client.
|
||||
type DrivesDriveItemApi struct {
|
||||
logger log.Logger
|
||||
drivesDriveItemService DrivesDriveItemProvider
|
||||
baseGraphService BaseGraphProvider
|
||||
}
|
||||
|
||||
// NewDrivesDriveItemApi creates a new DrivesDriveItemApi
|
||||
func NewDrivesDriveItemApi(drivesDriveItemService DrivesDriveItemProvider, baseGraphService BaseGraphProvider, logger log.Logger) (DrivesDriveItemApi, error) {
|
||||
return DrivesDriveItemApi{
|
||||
logger: log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemApi").Logger()},
|
||||
drivesDriveItemService: drivesDriveItemService,
|
||||
baseGraphService: baseGraphService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteDriveItem deletes a drive item
|
||||
func (api DrivesDriveItemApi) DeleteDriveItem(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
driveID, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
|
||||
ErrInvalidDriveIDOrItemID.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !IsShareJail(driveID) {
|
||||
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
|
||||
ErrNotAShareJail.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
shareID := ExtractShareIdFromResourceId(itemID)
|
||||
if err := api.drivesDriveItemService.UnmountShare(ctx, shareID); err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrUnmountShare.Error())
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// GetDriveItem get a drive item
|
||||
func (api DrivesDriveItemApi) GetDriveItem(w http.ResponseWriter, r *http.Request) {
|
||||
driveID, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
|
||||
ErrInvalidDriveIDOrItemID.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !IsShareJail(driveID) {
|
||||
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
|
||||
ErrNotAShareJail.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
shareID := ExtractShareIdFromResourceId(itemID)
|
||||
share, err := api.drivesDriveItemService.GetShare(r.Context(), shareID)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrNoShares.Error())
|
||||
ErrNoShares.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
availableShares, err := api.drivesDriveItemService.GetSharesForResource(r.Context(), share.GetShare().GetResourceId(), nil)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrNoShares.Error())
|
||||
ErrNoShares.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
driveItems, err := api.baseGraphService.CS3ReceivedSharesToDriveItems(r.Context(), availableShares)
|
||||
switch {
|
||||
case err != nil:
|
||||
break
|
||||
case len(driveItems) != 1:
|
||||
err = ErrDriveItemConversion
|
||||
}
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrDriveItemConversion.Error())
|
||||
ErrDriveItemConversion.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, driveItems[0])
|
||||
}
|
||||
|
||||
// UpdateDriveItem updates a drive item, currently only the visibility of the share is updated
|
||||
func (api DrivesDriveItemApi) UpdateDriveItem(w http.ResponseWriter, r *http.Request) {
|
||||
driveID, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
|
||||
ErrInvalidDriveIDOrItemID.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !IsShareJail(driveID) {
|
||||
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
|
||||
ErrNotAShareJail.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
shareID := ExtractShareIdFromResourceId(itemID)
|
||||
requestDriveItem := libregraph.DriveItem{}
|
||||
if err := StrictJSONUnmarshal(r.Body, &requestDriveItem); err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrInvalidRequestBody.Error())
|
||||
ErrInvalidRequestBody.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
share, err := api.drivesDriveItemService.GetShare(r.Context(), shareID)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrNoShares.Error())
|
||||
ErrNoShares.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
availableShares, err := api.drivesDriveItemService.GetSharesForResource(r.Context(), share.GetShare().GetResourceId(), nil)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrNoShares.Error())
|
||||
ErrNoShares.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
updatedShares, err := api.drivesDriveItemService.UpdateShares(
|
||||
r.Context(),
|
||||
availableShares,
|
||||
func(_ *collaboration.ReceivedShare, request *collaboration.UpdateReceivedShareRequest) {
|
||||
request.GetShare().Hidden = requestDriveItem.GetUIHidden()
|
||||
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathHidden)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrUpdateShares.Error())
|
||||
ErrUpdateShares.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
driveItems, err := api.baseGraphService.CS3ReceivedSharesToDriveItems(r.Context(), updatedShares)
|
||||
switch {
|
||||
case err != nil:
|
||||
break
|
||||
case len(driveItems) != 1:
|
||||
err = ErrDriveItemConversion
|
||||
}
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrDriveItemConversion.Error())
|
||||
ErrDriveItemConversion.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, driveItems[0])
|
||||
}
|
||||
|
||||
// CreateDriveItem creates a drive item
|
||||
func (api DrivesDriveItemApi) CreateDriveItem(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
driveID, err := parseIDParam(r, "driveID")
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
|
||||
ErrInvalidDriveIDOrItemID.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !IsShareJail(&driveID) {
|
||||
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
|
||||
ErrNotAShareJail.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
requestDriveItem := libregraph.DriveItem{}
|
||||
if err := StrictJSONUnmarshal(r.Body, &requestDriveItem); err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrInvalidRequestBody.Error())
|
||||
ErrInvalidRequestBody.Render(w, r)
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
remoteItem := requestDriveItem.GetRemoteItem()
|
||||
resourceId, err := storagespace.ParseID(remoteItem.GetId())
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrInvalidID.Error())
|
||||
ErrInvalidID.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var driveItems []libregraph.DriveItem
|
||||
switch {
|
||||
case resourceId.GetStorageId() == utils.OCMStorageProviderID:
|
||||
var mountedOcmShares []*ocm.ReceivedShare
|
||||
mountedOcmShares, err = api.drivesDriveItemService.MountOCMShare(ctx, &resourceId /*, requestDriveItem.GetName()*/)
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrMountShare.Error())
|
||||
|
||||
switch e, ok := errorcode.ToError(err); {
|
||||
case ok && e.GetOrigin() == errorcode.ErrorOriginCS3 && e.GetCode() == errorcode.ItemNotFound:
|
||||
ErrDriveItemConversion.Render(w, r)
|
||||
default:
|
||||
errorcode.RenderError(w, r, err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
driveItems, err = api.baseGraphService.CS3ReceivedOCMSharesToDriveItems(ctx, mountedOcmShares)
|
||||
default:
|
||||
var mountedShares []*collaboration.ReceivedShare
|
||||
// Get all shares that the user has received for this resource. There might be multiple
|
||||
mountedShares, err = api.drivesDriveItemService.MountShare(ctx, &resourceId, requestDriveItem.GetName())
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrMountShare.Error())
|
||||
|
||||
switch e, ok := errorcode.ToError(err); {
|
||||
case ok && e.GetOrigin() == errorcode.ErrorOriginCS3 && e.GetCode() == errorcode.ItemNotFound:
|
||||
ErrDriveItemConversion.Render(w, r)
|
||||
default:
|
||||
errorcode.RenderError(w, r, err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
driveItems, err = api.baseGraphService.CS3ReceivedSharesToDriveItems(ctx, mountedShares)
|
||||
}
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
break
|
||||
case len(driveItems) != 1:
|
||||
err = ErrDriveItemConversion
|
||||
}
|
||||
if err != nil {
|
||||
api.logger.Debug().Err(err).Msg(ErrDriveItemConversion.Error())
|
||||
ErrDriveItemConversion.Render(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusCreated)
|
||||
render.JSON(w, r, driveItems[0])
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUnmountOCMShare is returned when unmounting a share fails
|
||||
ErrUnmountOCMShare = errorcode.New(errorcode.InvalidRequest, "unmounting ocm share failed")
|
||||
|
||||
// ErrMountOCMShare is returned when mounting a share fails
|
||||
ErrMountOCMShare = errorcode.New(errorcode.InvalidRequest, "mounting ocm share failed")
|
||||
)
|
||||
|
||||
type (
|
||||
// UpdateOCMShareClosure is a closure that injects required updates into the update request
|
||||
UpdateOCMShareClosure func(share *ocm.ReceivedShare, request *ocm.UpdateReceivedOCMShareRequest)
|
||||
)
|
||||
|
||||
// GetOCMSharesForResource returns all federated shares for a given resourceID
|
||||
func (s DrivesDriveItemService) GetOCMSharesForResource(ctx context.Context, resourceID *storageprovider.ResourceId) ([]*ocm.ReceivedShare, error) {
|
||||
// Find all accepted shares for this resource
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
receivedOCMSharesResponse, err := gatewayClient.ListReceivedOCMShares(ctx, &ocm.ListReceivedOCMSharesRequest{
|
||||
/* ocm has no filters, yet
|
||||
Filters: append([]*collaboration.Filter{
|
||||
{
|
||||
Type: collaboration.Filter_TYPE_RESOURCE_ID,
|
||||
Term: &collaboration.Filter_ResourceId{
|
||||
ResourceId: resourceID,
|
||||
},
|
||||
},
|
||||
}, filters...),
|
||||
*/
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, err
|
||||
case len(receivedOCMSharesResponse.GetShares()) == 0:
|
||||
return nil, ErrNoShares
|
||||
default:
|
||||
return receivedOCMSharesResponse.GetShares(), errorcode.FromCS3Status(receivedOCMSharesResponse.GetStatus(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateShares updates multiple shares;
|
||||
// it could happen that some shares are updated and some are not,
|
||||
// this will return a list of updated shares and a list of errors;
|
||||
// there is no guarantee that all updates are successful
|
||||
func (s DrivesDriveItemService) UpdateOCMShares(ctx context.Context, shares []*ocm.ReceivedShare, updater UpdateOCMShareClosure) ([]*ocm.ReceivedShare, error) {
|
||||
errs := make([]error, 0, len(shares))
|
||||
updatedShares := make([]*ocm.ReceivedShare, 0, len(shares))
|
||||
|
||||
for _, share := range shares {
|
||||
err := s.UpdateOCMShare(
|
||||
ctx,
|
||||
share,
|
||||
updater,
|
||||
)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
|
||||
updatedShares = append(updatedShares, share)
|
||||
}
|
||||
|
||||
return updatedShares, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// UpdateOCMShare updates a single share
|
||||
func (s DrivesDriveItemService) UpdateOCMShare(ctx context.Context, share *ocm.ReceivedShare, updater UpdateOCMShareClosure) error {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updateReceivedOCMShareRequest := &ocm.UpdateReceivedOCMShareRequest{
|
||||
Share: &ocm.ReceivedShare{
|
||||
Id: share.GetId(),
|
||||
},
|
||||
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{}},
|
||||
}
|
||||
|
||||
switch updater {
|
||||
case nil:
|
||||
return ErrNoUpdater
|
||||
default:
|
||||
updater(share, updateReceivedOCMShareRequest)
|
||||
}
|
||||
|
||||
if len(updateReceivedOCMShareRequest.GetUpdateMask().GetPaths()) == 0 {
|
||||
return ErrNoUpdates
|
||||
}
|
||||
|
||||
updateReceivedOCMShareResponse, err := gatewayClient.UpdateReceivedOCMShare(ctx, updateReceivedOCMShareRequest)
|
||||
return errorcode.FromCS3Status(updateReceivedOCMShareResponse.GetStatus(), err)
|
||||
}
|
||||
|
||||
func (s DrivesDriveItemService) MountOCMShare(ctx context.Context, resourceID *storageprovider.ResourceId /*, name string*/) ([]*ocm.ReceivedShare, error) {
|
||||
/*
|
||||
if filepath.IsAbs(name) {
|
||||
return nil, ErrAbsoluteNamePath
|
||||
}
|
||||
|
||||
if name != "" {
|
||||
name = filepath.Clean(name)
|
||||
}
|
||||
*/
|
||||
|
||||
shares, err := s.GetOCMSharesForResource(ctx, resourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
availableShares := make([]*ocm.ReceivedShare, 0, len(shares))
|
||||
mountedShares := make([]*ocm.ReceivedShare, 0, 1)
|
||||
for _, v := range shares {
|
||||
switch v.GetState() {
|
||||
case ocm.ShareState_SHARE_STATE_ACCEPTED:
|
||||
mountedShares = append(mountedShares, v)
|
||||
case ocm.ShareState_SHARE_STATE_PENDING, ocm.ShareState_SHARE_STATE_REJECTED:
|
||||
availableShares = append(availableShares, v)
|
||||
}
|
||||
}
|
||||
if len(availableShares) == 0 {
|
||||
if len(mountedShares) > 0 {
|
||||
return nil, ErrAlreadyMounted
|
||||
}
|
||||
return nil, ErrNoShares
|
||||
}
|
||||
|
||||
updatedShares, err := s.UpdateOCMShares(ctx, availableShares, func(share *ocm.ReceivedShare, request *ocm.UpdateReceivedOCMShareRequest) {
|
||||
request.Share.State = ocm.ShareState_SHARE_STATE_ACCEPTED
|
||||
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathState)
|
||||
|
||||
// only update if mountPoint name is not empty and the path has changed
|
||||
/* ocm shares have no mount point???
|
||||
if name != "" {
|
||||
mountPoint := share.GetMountPoint()
|
||||
if mountPoint == nil {
|
||||
mountPoint = &storageprovider.Reference{}
|
||||
}
|
||||
|
||||
if filepath.Clean(mountPoint.GetPath()) != name {
|
||||
mountPoint.Path = name
|
||||
request.Share.MountPoint = mountPoint
|
||||
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathMountPoint)
|
||||
}
|
||||
}
|
||||
*/
|
||||
})
|
||||
|
||||
errs, ok := err.(interface{ Unwrap() []error })
|
||||
if ok && len(errs.Unwrap()) == len(availableShares) {
|
||||
// none of the received ocm shares could be accepted.
|
||||
// this is an error, return it.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedShares, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/render"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/metadata"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/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, r io.Reader) error
|
||||
|
||||
// DeletePhoto deletes the requested photo
|
||||
DeletePhoto(ctx context.Context, id string) error
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoBytes is returned when no bytes are found
|
||||
ErrNoBytes = errors.New("no bytes")
|
||||
|
||||
// ErrInvalidContentType is returned when the content type is invalid
|
||||
ErrInvalidContentType = errors.New("invalid content type")
|
||||
|
||||
// ErrMissingArgument is returned when a required argument is missing
|
||||
ErrMissingArgument = errors.New("required argument is missing")
|
||||
)
|
||||
|
||||
// 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) {
|
||||
return UsersUserProfilePhotoService{
|
||||
storage: storage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPhoto retrieves the requested photo
|
||||
func (s UsersUserProfilePhotoService) GetPhoto(ctx context.Context, id string) ([]byte, error) {
|
||||
return s.storage.SimpleDownload(ctx, id)
|
||||
}
|
||||
|
||||
// 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, r io.Reader) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("%w: %s", ErrMissingArgument, "id")
|
||||
}
|
||||
|
||||
photo, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(photo) == 0 {
|
||||
return ErrNoBytes
|
||||
}
|
||||
|
||||
contentType := http.DetectContentType(photo)
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidContentType, contentType)
|
||||
}
|
||||
|
||||
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 creates a handler which renders the corresponding photo
|
||||
func (api UsersUserProfilePhotoApi) GetProfilePhoto(h HTTPDataHandler[string]) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok := h(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
photo, err := api.usersUserProfilePhotoService.GetPhoto(r.Context(), v)
|
||||
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 creates a handler which updates or creates the corresponding photo
|
||||
func (api UsersUserProfilePhotoApi) UpsertProfilePhoto(h HTTPDataHandler[string]) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok := h(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.usersUserProfilePhotoService.UpdatePhoto(r.Context(), v, r.Body); err != nil {
|
||||
api.logger.Debug().Err(err)
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "failed to update photo")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteProfilePhoto creates a handler which deletes the corresponding photo
|
||||
func (api UsersUserProfilePhotoApi) DeleteProfilePhoto(h HTTPDataHandler[string]) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok := h(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.usersUserProfilePhotoService.DeletePhoto(r.Context(), v); err != nil {
|
||||
api.logger.Debug().Err(err)
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "failed to delete photo")
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/graph/mocks"
|
||||
svc "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
)
|
||||
|
||||
func TestNewUsersUserProfilePhotoService(t *testing.T) {
|
||||
service, err := svc.NewUsersUserProfilePhotoService(mocks.NewStorage(t))
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("UpdatePhoto", func(t *testing.T) {
|
||||
t.Run("reports an error if id is empty", func(t *testing.T) {
|
||||
err := service.UpdatePhoto(context.Background(), "", bytes.NewReader([]byte{}))
|
||||
assert.ErrorIs(t, err, svc.ErrMissingArgument)
|
||||
})
|
||||
|
||||
t.Run("reports an error if the reader does not contain any bytes", func(t *testing.T) {
|
||||
err := service.UpdatePhoto(context.Background(), "123", bytes.NewReader([]byte{}))
|
||||
assert.ErrorIs(t, err, svc.ErrNoBytes)
|
||||
})
|
||||
|
||||
t.Run("reports an error if data is not an image", func(t *testing.T) {
|
||||
err := service.UpdatePhoto(context.Background(), "234", bytes.NewReader([]byte("not an image")))
|
||||
assert.ErrorIs(t, err, svc.ErrInvalidContentType)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersUserProfilePhotoApi(t *testing.T) {
|
||||
var (
|
||||
serviceProvider = mocks.NewUsersUserProfilePhotoProvider(t)
|
||||
dataProvider = func(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
return "123", true
|
||||
}
|
||||
)
|
||||
|
||||
api, err := svc.NewUsersUserProfilePhotoApi(serviceProvider, log.NopLogger())
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("GetProfilePhoto", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
ep := api.GetProfilePhoto(dataProvider)
|
||||
|
||||
t.Run("fails if photo provider errors", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serviceProvider.EXPECT().GetPhoto(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string) ([]byte, error) {
|
||||
return nil, errors.New("any")
|
||||
}).Once()
|
||||
|
||||
ep.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
|
||||
t.Run("successfully returns the requested photo", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serviceProvider.EXPECT().GetPhoto(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string) ([]byte, error) {
|
||||
return []byte("photo"), nil
|
||||
}).Once()
|
||||
|
||||
ep.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "photo", w.Body.String())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("DeleteProfilePhoto", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/", nil)
|
||||
ep := api.DeleteProfilePhoto(dataProvider)
|
||||
|
||||
t.Run("fails if photo provider errors", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serviceProvider.EXPECT().DeletePhoto(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string) error {
|
||||
return errors.New("any")
|
||||
}).Once()
|
||||
|
||||
ep.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
})
|
||||
|
||||
t.Run("successfully deletes the requested photo", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serviceProvider.EXPECT().DeletePhoto(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string) error {
|
||||
return nil
|
||||
}).Once()
|
||||
|
||||
ep.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("UpsertProfilePhoto", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPut, "/", strings.NewReader("body"))
|
||||
ep := api.UpsertProfilePhoto(dataProvider)
|
||||
|
||||
t.Run("fails if photo provider errors", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serviceProvider.EXPECT().UpdatePhoto(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string, r io.Reader) error {
|
||||
return errors.New("any")
|
||||
}).Once()
|
||||
|
||||
ep.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
})
|
||||
|
||||
t.Run("successfully upserts the photo", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serviceProvider.EXPECT().UpdatePhoto(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string, r io.Reader) error {
|
||||
return nil
|
||||
}).Once()
|
||||
|
||||
ep.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
// ListApplications implements the Service interface.
|
||||
func (g Graph) ListApplications(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Interface("query", r.URL.Query()).Msg("calling list applications")
|
||||
|
||||
lbr, err := g.roleService.ListRoles(r.Context(), &settingssvc.ListBundlesRequest{})
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not list roles: transport error")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
roles := make([]libregraph.AppRole, 0, len(lbr.Bundles))
|
||||
for _, bundle := range lbr.GetBundles() {
|
||||
role := libregraph.NewAppRole(bundle.GetId())
|
||||
role.SetDisplayName(bundle.GetDisplayName())
|
||||
roles = append(roles, *role)
|
||||
}
|
||||
|
||||
application := libregraph.NewApplication(g.config.Application.ID)
|
||||
application.SetDisplayName(g.config.Application.DisplayName)
|
||||
application.SetAppRoles(roles)
|
||||
|
||||
applications := []*libregraph.Application{
|
||||
application,
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &ListResponse{Value: applications})
|
||||
}
|
||||
|
||||
// GetApplication implements the Service interface.
|
||||
func (g Graph) GetApplication(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Interface("query", r.URL.Query()).Msg("calling get application")
|
||||
|
||||
applicationID := chi.URLParam(r, "applicationID")
|
||||
|
||||
if applicationID != g.config.Application.ID {
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, fmt.Sprintf("requested id %s does not match expected application id %v", applicationID, g.config.Application.ID))
|
||||
return
|
||||
}
|
||||
|
||||
lbr, err := g.roleService.ListRoles(r.Context(), &settingssvc.ListBundlesRequest{})
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not list roles: transport error")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
roles := make([]libregraph.AppRole, 0, len(lbr.Bundles))
|
||||
for _, bundle := range lbr.GetBundles() {
|
||||
role := libregraph.NewAppRole(bundle.GetId())
|
||||
role.SetDisplayName(bundle.GetDisplayName())
|
||||
roles = append(roles, *role)
|
||||
}
|
||||
|
||||
application := libregraph.NewApplication(applicationID)
|
||||
application.SetDisplayName(g.config.Application.DisplayName)
|
||||
application.SetAppRoles(roles)
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, application)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
|
||||
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/graph/mocks"
|
||||
"github.com/qsfera/server/services/graph/pkg/config"
|
||||
"github.com/qsfera/server/services/graph/pkg/config/defaults"
|
||||
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
|
||||
service "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
)
|
||||
|
||||
type applicationList struct {
|
||||
Value []*libregraph.Application
|
||||
}
|
||||
|
||||
var _ = Describe("Applications", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
eventsPublisher mocks.Publisher
|
||||
roleService *mocks.RoleService
|
||||
identityBackend *identitymocks.Backend
|
||||
|
||||
rr *httptest.ResponseRecorder
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
identityBackend = &identitymocks.Backend{}
|
||||
roleService = &mocks.RoleService{}
|
||||
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
ctx = context.Background()
|
||||
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
cfg.Commons = &shared.Commons{}
|
||||
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
|
||||
cfg.Application.ID = "some-application-ID"
|
||||
|
||||
var err error
|
||||
svc, err = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
service.WithIdentityBackend(identityBackend),
|
||||
service.WithRoleService(roleService),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("ListApplications", func() {
|
||||
It("lists the configured application with appRoles", func() {
|
||||
roleService.On("ListRoles", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListBundlesResponse{
|
||||
Bundles: []*settingsmsg.Bundle{
|
||||
{
|
||||
Id: "some-appRole-ID",
|
||||
Type: settingsmsg.Bundle_TYPE_ROLE,
|
||||
DisplayName: "A human readable name for a role",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/applications", nil)
|
||||
svc.ListApplications(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
responseList := applicationList{}
|
||||
err = json.Unmarshal(data, &responseList)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(responseList.Value)).To(Equal(1))
|
||||
Expect(responseList.Value[0].Id).To(Equal(cfg.Application.ID))
|
||||
Expect(len(responseList.Value[0].GetAppRoles())).To(Equal(1))
|
||||
Expect(responseList.Value[0].GetAppRoles()[0].GetId()).To(Equal("some-appRole-ID"))
|
||||
Expect(responseList.Value[0].GetAppRoles()[0].GetDisplayName()).To(Equal("A human readable name for a role"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetApplication", func() {
|
||||
It("gets the application with appRoles", func() {
|
||||
roleService.On("ListRoles", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListBundlesResponse{
|
||||
Bundles: []*settingsmsg.Bundle{
|
||||
{
|
||||
Id: "some-appRole-ID",
|
||||
Type: settingsmsg.Bundle_TYPE_ROLE,
|
||||
DisplayName: "A human readable name for a role",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/applications/some-application-ID", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("applicationID", cfg.Application.ID)
|
||||
r = r.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
|
||||
svc.GetApplication(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
application := libregraph.Application{}
|
||||
err = json.Unmarshal(data, &application)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(application.Id).To(Equal(cfg.Application.ID))
|
||||
Expect(len(application.GetAppRoles())).To(Equal(1))
|
||||
Expect(application.GetAppRoles()[0].GetId()).To(Equal("some-appRole-ID"))
|
||||
Expect(application.GetAppRoles()[0].GetDisplayName()).To(Equal("A human readable name for a role"))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
|
||||
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
merrors "go-micro.dev/v4/errors"
|
||||
)
|
||||
|
||||
const principalTypeUser = "User"
|
||||
|
||||
// ListAppRoleAssignments implements the Service interface.
|
||||
func (g Graph) ListAppRoleAssignments(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Interface("query", r.URL.Query()).Msg("calling list appRoleAssignments")
|
||||
|
||||
userID := chi.URLParam(r, "userID")
|
||||
|
||||
lrar, err := g.roleService.ListRoleAssignments(r.Context(), &settingssvc.ListRoleAssignmentsRequest{
|
||||
AccountUuid: userID,
|
||||
})
|
||||
if err != nil {
|
||||
// TODO check the error type and return proper error code
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
values := make([]libregraph.AppRoleAssignment, 0, len(lrar.GetAssignments()))
|
||||
for _, assignment := range lrar.GetAssignments() {
|
||||
values = append(values, g.assignmentToAppRoleAssignment(assignment))
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &ListResponse{Value: values})
|
||||
}
|
||||
|
||||
// CreateAppRoleAssignment implements the Service interface.
|
||||
func (g Graph) CreateAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Interface("query", r.URL.Query()).Msg("calling create appRoleAssignment")
|
||||
|
||||
appRoleAssignment := libregraph.NewAppRoleAssignmentWithDefaults()
|
||||
err := StrictJSONUnmarshal(r.Body, appRoleAssignment)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
userID := chi.URLParam(r, "userID")
|
||||
|
||||
// We can ignore the error, in the worst case the old role will be empty
|
||||
oldRoles, _ := g.roleService.ListRoleAssignments(r.Context(), &settingssvc.ListRoleAssignmentsRequest{
|
||||
AccountUuid: userID,
|
||||
})
|
||||
|
||||
if appRoleAssignment.GetPrincipalId() != userID {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("user id %s does not match principal id %v", userID, appRoleAssignment.GetPrincipalId()))
|
||||
return
|
||||
}
|
||||
if appRoleAssignment.GetResourceId() != g.config.Application.ID {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("resource id %s does not match expected application id %v", userID, g.config.Application.ID))
|
||||
return
|
||||
}
|
||||
|
||||
artur, err := g.roleService.AssignRoleToUser(r.Context(), &settingssvc.AssignRoleToUserRequest{
|
||||
AccountUuid: userID,
|
||||
RoleId: appRoleAssignment.AppRoleId,
|
||||
})
|
||||
if err != nil {
|
||||
if merr, ok := merrors.As(err); ok && merr.Code == http.StatusForbidden {
|
||||
errorcode.NotAllowed.Render(w, r, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var role string
|
||||
roles := oldRoles.GetAssignments()
|
||||
if len(roles) > 0 {
|
||||
role = roles[0].GetRoleId()
|
||||
}
|
||||
|
||||
e := events.UserFeatureChanged{
|
||||
UserID: userID,
|
||||
Features: []events.UserFeature{
|
||||
{
|
||||
Name: "roleChanged",
|
||||
Value: appRoleAssignment.AppRoleId,
|
||||
OldValue: &role,
|
||||
},
|
||||
},
|
||||
Timestamp: utils.TSNow(),
|
||||
}
|
||||
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(r.Context(), e)
|
||||
render.Status(r, http.StatusCreated)
|
||||
render.JSON(w, r, g.assignmentToAppRoleAssignment(artur.GetAssignment()))
|
||||
}
|
||||
|
||||
// DeleteAppRoleAssignment implements the Service interface.
|
||||
func (g Graph) DeleteAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Interface("body", r.Body).Msg("calling delete appRoleAssignment")
|
||||
|
||||
userID := chi.URLParam(r, "userID")
|
||||
|
||||
// check assignment belongs to the user
|
||||
lrar, err := g.roleService.ListRoleAssignments(r.Context(), &settingssvc.ListRoleAssignmentsRequest{
|
||||
AccountUuid: userID,
|
||||
})
|
||||
if err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
appRoleAssignmentID := chi.URLParam(r, "appRoleAssignmentID")
|
||||
|
||||
assignmentFound := false
|
||||
for _, roleAssignment := range lrar.GetAssignments() {
|
||||
if roleAssignment.Id == appRoleAssignmentID {
|
||||
assignmentFound = true
|
||||
}
|
||||
}
|
||||
if !assignmentFound {
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, fmt.Sprintf("appRoleAssignment %v not found for user %v", appRoleAssignmentID, userID))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = g.roleService.RemoveRoleFromUser(r.Context(), &settingssvc.RemoveRoleFromUserRequest{
|
||||
Id: appRoleAssignmentID,
|
||||
})
|
||||
if err != nil {
|
||||
if merr, ok := merrors.As(err); ok && merr.Code == http.StatusForbidden {
|
||||
errorcode.NotAllowed.Render(w, r, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (g Graph) assignmentToAppRoleAssignment(assignment *settingsmsg.UserRoleAssignment) libregraph.AppRoleAssignment {
|
||||
appRoleAssignment := libregraph.NewAppRoleAssignmentWithDefaults()
|
||||
appRoleAssignment.SetId(assignment.Id)
|
||||
appRoleAssignment.SetAppRoleId(assignment.RoleId)
|
||||
appRoleAssignment.SetPrincipalType(principalTypeUser) // currently always assigned to the user
|
||||
appRoleAssignment.SetResourceId(g.config.Application.ID)
|
||||
appRoleAssignment.SetResourceDisplayName(g.config.Application.DisplayName)
|
||||
appRoleAssignment.SetPrincipalId(assignment.AccountUuid)
|
||||
// appRoleAssignment.SetPrincipalDisplayName() // TODO fetch and cache
|
||||
return *appRoleAssignment
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/golang/protobuf/ptypes/empty"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
|
||||
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/graph/mocks"
|
||||
"github.com/qsfera/server/services/graph/pkg/config"
|
||||
"github.com/qsfera/server/services/graph/pkg/config/defaults"
|
||||
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
|
||||
service "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
)
|
||||
|
||||
type assignmentList struct {
|
||||
Value []*libregraph.AppRoleAssignment
|
||||
}
|
||||
|
||||
var _ = Describe("AppRoleAssignments", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
eventsPublisher mocks.Publisher
|
||||
roleService *mocks.RoleService
|
||||
identityBackend *identitymocks.Backend
|
||||
|
||||
rr *httptest.ResponseRecorder
|
||||
|
||||
currentUser = &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "user",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
identityBackend = &identitymocks.Backend{}
|
||||
roleService = &mocks.RoleService{}
|
||||
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
ctx = context.Background()
|
||||
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
cfg.Commons = &shared.Commons{}
|
||||
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
|
||||
cfg.Application.ID = "some-application-ID"
|
||||
|
||||
var err error
|
||||
svc, err = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
service.WithIdentityBackend(identityBackend),
|
||||
service.WithRoleService(roleService),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("ListAppRoleAssignments", func() {
|
||||
It("lists the appRoleAssignments", func() {
|
||||
user := &libregraph.User{
|
||||
Id: libregraph.PtrString("user1"),
|
||||
}
|
||||
assignments := []*settingsmsg.UserRoleAssignment{
|
||||
{
|
||||
Id: "some-appRoleAssignment-ID",
|
||||
AccountUuid: user.GetId(),
|
||||
RoleId: "some-appRole-ID",
|
||||
},
|
||||
}
|
||||
roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{Assignments: assignments}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users/user1/appRoleAssignments", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", user.GetId())
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.ListAppRoleAssignments(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
responseList := assignmentList{}
|
||||
err = json.Unmarshal(data, &responseList)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(responseList.Value)).To(Equal(1))
|
||||
Expect(responseList.Value[0].GetId()).ToNot(BeEmpty())
|
||||
Expect(responseList.Value[0].GetAppRoleId()).To(Equal("some-appRole-ID"))
|
||||
Expect(responseList.Value[0].GetPrincipalId()).To(Equal(user.GetId()))
|
||||
Expect(responseList.Value[0].GetResourceId()).To(Equal(cfg.Application.ID))
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Describe("CreateAppRoleAssignment", func() {
|
||||
It("creates an appRoleAssignment", func() {
|
||||
user := &libregraph.User{
|
||||
Id: libregraph.PtrString("user1"),
|
||||
}
|
||||
userRoleAssignment := &settingsmsg.UserRoleAssignment{
|
||||
Id: "some-appRoleAssignment-ID",
|
||||
AccountUuid: user.GetId(),
|
||||
RoleId: "some-appRole-ID",
|
||||
}
|
||||
roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{
|
||||
Assignments: []*settingsmsg.UserRoleAssignment{
|
||||
userRoleAssignment,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
roleService.On("AssignRoleToUser", mock.Anything, mock.Anything, mock.Anything).Return(&settings.AssignRoleToUserResponse{Assignment: userRoleAssignment}, nil)
|
||||
|
||||
ara := libregraph.NewAppRoleAssignmentWithDefaults()
|
||||
ara.SetAppRoleId("some-appRole-ID")
|
||||
ara.SetPrincipalId(user.GetId())
|
||||
ara.SetResourceId(cfg.Application.ID)
|
||||
|
||||
araJson, err := json.Marshal(ara)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/users/user1/appRoleAssignments", bytes.NewBuffer(araJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", user.GetId())
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.CreateAppRoleAssignment(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusCreated))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assignment := libregraph.AppRoleAssignment{}
|
||||
err = json.Unmarshal(data, &assignment)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(assignment.GetId()).ToNot(BeEmpty())
|
||||
Expect(assignment.GetAppRoleId()).To(Equal("some-appRole-ID"))
|
||||
Expect(assignment.GetPrincipalId()).To(Equal("user1"))
|
||||
Expect(assignment.GetResourceId()).To(Equal(cfg.Application.ID))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Describe("DeleteAppRoleAssignment", func() {
|
||||
It("deletes an appRoleAssignment", func() {
|
||||
user := &libregraph.User{
|
||||
Id: libregraph.PtrString("user1"),
|
||||
}
|
||||
|
||||
assignments := []*settingsmsg.UserRoleAssignment{
|
||||
{
|
||||
Id: "some-appRoleAssignment-ID",
|
||||
AccountUuid: user.GetId(),
|
||||
RoleId: "some-appRole-ID",
|
||||
},
|
||||
}
|
||||
roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{Assignments: assignments}, nil)
|
||||
|
||||
roleService.On("RemoveRoleFromUser", mock.Anything, mock.Anything, mock.Anything).Return(&empty.Empty{}, nil)
|
||||
|
||||
ara := libregraph.NewAppRoleAssignmentWithDefaults()
|
||||
ara.SetAppRoleId("some-appRole-ID")
|
||||
ara.SetPrincipalId(user.GetId())
|
||||
ara.SetResourceId(cfg.Application.ID)
|
||||
|
||||
araJson, err := json.Marshal(ara)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/users/user1/appRoleAssignments/some-appRoleAssignment-ID", bytes.NewBuffer(araJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", user.GetId())
|
||||
rctx.URLParams.Add("appRoleAssignmentID", "some-appRoleAssignment-ID")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteAppRoleAssignment(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
)
|
||||
|
||||
// HTTPDataHandler returns data from the request, it should exit early and return false in the case of any error
|
||||
type HTTPDataHandler[T any] func(w http.ResponseWriter, r *http.Request) (T, bool)
|
||||
|
||||
var (
|
||||
// ErrNoUser is returned when no user is found
|
||||
ErrNoUser = errors.New("no user found")
|
||||
)
|
||||
|
||||
// GetUserIDFromCTX extracts the user from the request
|
||||
func GetUserIDFromCTX(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
u, ok := revactx.ContextGetUser(r.Context())
|
||||
if !ok {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusMethodNotAllowed, ErrNoUser.Error())
|
||||
}
|
||||
|
||||
return u.GetId().GetOpaqueId(), ok
|
||||
}
|
||||
|
||||
func GetSlugValue(key string) HTTPDataHandler[string] {
|
||||
return func(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
v, err := url.PathUnescape(chi.URLParam(r, key))
|
||||
if err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, fmt.Sprintf(`failed to get slug: "%s"`, key))
|
||||
}
|
||||
|
||||
return v, err == nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"golang.org/x/crypto/sha3"
|
||||
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
)
|
||||
|
||||
// CreateUploadSession create an upload session to allow your app to upload files up to the maximum file size.
|
||||
// An upload session allows your app to upload ranges of the file in sequential API requests, which allows the
|
||||
// transfer to be resumed if a connection is dropped while the upload is in progress.
|
||||
// ```json
|
||||
//
|
||||
// {
|
||||
// "@microsoft.graph.conflictBehavior": "fail (default) | replace | rename",
|
||||
// "description": "description",
|
||||
// "fileSize": 1234,
|
||||
// "name": "filename.txt"
|
||||
// }
|
||||
//
|
||||
// ```
|
||||
// From https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession?view=graph-rest-1.0
|
||||
func (g Graph) CreateUploadSession(w http.ResponseWriter, r *http.Request) {
|
||||
g.logger.Info().Msg("Calling CreateUploadSession")
|
||||
|
||||
driveID, err := parseIDParam(r, "driveID")
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
driveItemID, err := parseIDParam(r, "driveItemID")
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() {
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
|
||||
return
|
||||
}
|
||||
|
||||
var cusr createUploadSessionRequest
|
||||
err = json.NewDecoder(r.Body).Decode(&cusr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select next gateway client, aborting")
|
||||
return
|
||||
}
|
||||
|
||||
ref := &storageprovider.Reference{
|
||||
ResourceId: &driveItemID,
|
||||
}
|
||||
if cusr.Item.Name != "" {
|
||||
ref.Path = utils.MakeRelativePath(cusr.Item.Name)
|
||||
}
|
||||
req := &storageprovider.InitiateFileUploadRequest{
|
||||
Ref: ref,
|
||||
Opaque: utils.AppendPlainToOpaque(nil, "Upload-Length", strconv.FormatUint(uint64(cusr.Item.FileSize), 10)),
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
res, err := gatewayClient.InitiateFileUpload(ctx, req)
|
||||
switch {
|
||||
case err != nil:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
|
||||
// ok
|
||||
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
|
||||
return
|
||||
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED:
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
|
||||
return
|
||||
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED:
|
||||
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
|
||||
return
|
||||
default:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
|
||||
return
|
||||
}
|
||||
uploadSession := uploadSession{
|
||||
CS3Protocols: res.GetProtocols(),
|
||||
}
|
||||
for _, p := range res.GetProtocols() {
|
||||
if p.GetProtocol() == "simple" {
|
||||
uploadSession.UploadURL = p.GetUploadEndpoint() + "/" + p.GetToken()
|
||||
}
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &uploadSession)
|
||||
}
|
||||
|
||||
type createUploadSessionRequest struct {
|
||||
DeferCommit bool `json:"deferCommit"`
|
||||
Item driveItemUploadableProperties `json:"item"`
|
||||
}
|
||||
type driveItemUploadableProperties struct {
|
||||
// ConflictBehavior "@microsoft.graph.conflictBehavior"
|
||||
//Description string
|
||||
FileSize int64 `json:"fileSize"`
|
||||
// fileSystemInfo
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type uploadSession struct {
|
||||
UploadURL string
|
||||
//"expirationDateTime": "2015-01-29T09:21:55.523Z",
|
||||
//"nextExpectedRanges": ["0-"]
|
||||
CS3Protocols []*gateway.FileUploadProtocol
|
||||
}
|
||||
|
||||
// GetRootDriveChildren implements the Service interface.
|
||||
func (g Graph) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) {
|
||||
g.logger.Info().Msg("Calling GetRootDriveChildren")
|
||||
ctx := r.Context()
|
||||
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select next gateway client, aborting")
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := revactx.ContextMustGetUser(r.Context())
|
||||
// do we need to list all or only the personal drive
|
||||
filters := []*storageprovider.ListStorageSpacesRequest_Filter{}
|
||||
filters = append(filters, listStorageSpacesUserFilter(currentUser.GetId().GetOpaqueId()))
|
||||
filters = append(filters, listStorageSpacesTypeFilter("personal"))
|
||||
|
||||
res, err := gatewayClient.ListStorageSpaces(ctx, &storageprovider.ListStorageSpacesRequest{
|
||||
Filters: filters,
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
g.logger.Error().Err(err).Msg("error making ListStorageSpaces grpc call")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
case res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK:
|
||||
if res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND {
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
|
||||
return
|
||||
}
|
||||
g.logger.Error().Err(err).Msg("error sending ListStorageSpaces grpc request")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
|
||||
return
|
||||
}
|
||||
|
||||
var space *storageprovider.StorageSpace
|
||||
for _, s := range res.GetStorageSpaces() {
|
||||
if utils.UserIDEqual(currentUser.GetId(), s.GetOwner().GetId()) {
|
||||
space = s
|
||||
}
|
||||
}
|
||||
|
||||
lRes, err := gatewayClient.ListContainer(ctx, &storageprovider.ListContainerRequest{
|
||||
Ref: &storageprovider.Reference{ResourceId: space.GetRoot()},
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
g.logger.Error().Err(err).Msg("error making ListContainer grpc call")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
case lRes.GetStatus().GetCode() != cs3rpc.Code_CODE_OK:
|
||||
if lRes.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND {
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, lRes.GetStatus().GetMessage())
|
||||
return
|
||||
}
|
||||
if lRes.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED {
|
||||
// TODO check if we should return 404 to not disclose existing items
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, lRes.GetStatus().GetMessage())
|
||||
return
|
||||
}
|
||||
g.logger.Error().Err(err).Msg("error sending list container grpc request")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
|
||||
return
|
||||
}
|
||||
|
||||
files, err := formatDriveItems(g.logger, g.publicBaseURL, lRes.GetInfos())
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error encoding response as json")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &ListResponse{Value: files})
|
||||
}
|
||||
|
||||
// GetDriveItem returns a driveItem
|
||||
func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) {
|
||||
g.logger.Info().Msg("Calling GetDriveItem")
|
||||
ctx := r.Context()
|
||||
|
||||
driveID, err := parseIDParam(r, "driveID")
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
driveItemID, err := parseIDParam(r, "driveItemID")
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() {
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
|
||||
return
|
||||
}
|
||||
/*
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
// Parse the request with odata parser
|
||||
odataReq, err := godata.ParseRequest(ctx, sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
*/
|
||||
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: &driveItemID}})
|
||||
switch {
|
||||
case err != nil:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
|
||||
// ok
|
||||
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
|
||||
return
|
||||
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED:
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
|
||||
return
|
||||
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED:
|
||||
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
|
||||
return
|
||||
default:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
|
||||
return
|
||||
}
|
||||
driveItem, err := cs3ResourceToDriveItem(g.logger, g.publicBaseURL, res.GetInfo())
|
||||
if err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &driveItem)
|
||||
}
|
||||
|
||||
// GetDriveItemChildren lists the children of a driveItem
|
||||
func (g Graph) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) {
|
||||
g.logger.Info().Msg("Calling GetDriveItemChildren")
|
||||
ctx := r.Context()
|
||||
|
||||
driveID, err := parseIDParam(r, "driveID")
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
driveItemID, err := parseIDParam(r, "driveItemID")
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() {
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
|
||||
return
|
||||
}
|
||||
/*
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
// Parse the request with odata parser
|
||||
odataReq, err := godata.ParseRequest(ctx, sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
*/
|
||||
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res, err := gatewayClient.ListContainer(ctx, &storageprovider.ListContainerRequest{
|
||||
Ref: &storageprovider.Reference{ResourceId: &driveItemID},
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
|
||||
// ok
|
||||
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
|
||||
return
|
||||
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED:
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
|
||||
return
|
||||
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED:
|
||||
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
|
||||
return
|
||||
default:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
|
||||
return
|
||||
}
|
||||
|
||||
files, err := formatDriveItems(g.logger, g.publicBaseURL, res.GetInfos())
|
||||
if err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &ListResponse{Value: files})
|
||||
}
|
||||
|
||||
func (g Graph) getRemoteItem(ctx context.Context, root *storageprovider.ResourceId, baseURL *url.URL) (*libregraph.RemoteItem, error) {
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ref := &storageprovider.Reference{
|
||||
ResourceId: root,
|
||||
}
|
||||
res, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: ref})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
|
||||
// Only log this, there could be mountpoints which have no grant
|
||||
g.logger.Debug().Msg(res.GetStatus().GetMessage())
|
||||
return nil, errors.New("could not fetch grant resource for the mountpoint")
|
||||
}
|
||||
item, err := cs3ResourceToRemoteItem(res.GetInfo())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if baseURL != nil && res.GetInfo() != nil && res.GetInfo().GetSpace() != nil {
|
||||
// TODO read from StorageSpace ... needs Opaque for now
|
||||
// TODO how do we build the url?
|
||||
// for now: read from request
|
||||
item.Name = libregraph.PtrString(res.GetInfo().GetName())
|
||||
if res.GetInfo().GetSpace().GetRoot() != nil {
|
||||
webDavURL := *baseURL
|
||||
relativePath := res.GetInfo().GetPath()
|
||||
webDavURL.Path = path.Join(webDavURL.Path, storagespace.FormatResourceID(res.GetInfo().GetSpace().GetRoot()), relativePath)
|
||||
item.WebDavUrl = libregraph.PtrString(webDavURL.String())
|
||||
}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func formatDriveItems(logger *log.Logger, publicBaseURL *url.URL, mds []*storageprovider.ResourceInfo) ([]*libregraph.DriveItem, error) {
|
||||
responses := make([]*libregraph.DriveItem, 0, len(mds))
|
||||
for i := range mds {
|
||||
res, err := cs3ResourceToDriveItem(logger, publicBaseURL, mds[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responses = append(responses, res)
|
||||
}
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
func cs3TimestampToTime(t *types.Timestamp) time.Time {
|
||||
return time.Unix(int64(t.GetSeconds()), int64(t.GetNanos()))
|
||||
}
|
||||
|
||||
func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *storageprovider.ResourceInfo) (*libregraph.DriveItem, error) {
|
||||
size := new(int64)
|
||||
*size = int64(res.GetSize()) // TODO lurking overflow: make size of libregraph drive item use uint64
|
||||
|
||||
driveItem := &libregraph.DriveItem{
|
||||
Id: libregraph.PtrString(storagespace.FormatResourceID(res.GetId())),
|
||||
Size: size,
|
||||
}
|
||||
|
||||
webURL := *publicBaseURL
|
||||
webURL.Path = path.Join(webURL.Path, "f", storagespace.FormatResourceID(res.GetId()))
|
||||
driveItem.WebUrl = libregraph.PtrString(webURL.String())
|
||||
|
||||
if name := path.Base(res.GetPath()); name != "" {
|
||||
driveItem.Name = &name
|
||||
}
|
||||
if res.GetEtag() != "" {
|
||||
driveItem.ETag = &res.Etag
|
||||
}
|
||||
if res.GetMtime() != nil {
|
||||
lastModified := cs3TimestampToTime(res.GetMtime())
|
||||
driveItem.LastModifiedDateTime = &lastModified
|
||||
}
|
||||
if res.GetParentId() != nil {
|
||||
parentRef := libregraph.NewItemReference()
|
||||
parentRef.SetDriveType(res.GetSpace().GetSpaceType())
|
||||
parentRef.SetDriveId(storagespace.FormatStorageID(res.GetParentId().GetStorageId(), res.GetParentId().GetSpaceId()))
|
||||
parentRef.SetId(storagespace.FormatResourceID(res.GetParentId()))
|
||||
parentRef.SetName(path.Base(path.Dir(res.GetPath())))
|
||||
parentRef.SetPath(path.Dir(res.GetPath()))
|
||||
driveItem.ParentReference = parentRef
|
||||
}
|
||||
switch res.GetType() {
|
||||
case storageprovider.ResourceType_RESOURCE_TYPE_FILE:
|
||||
mimeType := res.GetMimeType()
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
// We cannot use a libregraph.File here because the openapi codegenerator autodetects 'File' as a go type ...
|
||||
driveItem.File = &libregraph.OpenGraphFile{
|
||||
MimeType: &mimeType,
|
||||
}
|
||||
case storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER:
|
||||
if IsSpaceRoot(res.GetId()) {
|
||||
driveItem.SetRoot(map[string]any{})
|
||||
} else {
|
||||
driveItem.SetFolder(libregraph.Folder{})
|
||||
}
|
||||
}
|
||||
|
||||
if res.GetArbitraryMetadata() != nil {
|
||||
driveItem.Audio = cs3ResourceToDriveItemAudioFacet(logger, res)
|
||||
driveItem.Image = cs3ResourceToDriveItemImageFacet(logger, res)
|
||||
driveItem.Location = cs3ResourceToDriveItemLocationFacet(logger, res)
|
||||
driveItem.Photo = cs3ResourceToDriveItemPhotoFacet(logger, res)
|
||||
}
|
||||
|
||||
return driveItem, nil
|
||||
}
|
||||
|
||||
func cs3ResourceToDriveItemAudioFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Audio {
|
||||
if !strings.HasPrefix(res.GetMimeType(), "audio/") {
|
||||
return nil
|
||||
}
|
||||
|
||||
k := res.GetArbitraryMetadata().GetMetadata()
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var audio = &libregraph.Audio{}
|
||||
if ok := unmarshalStringMap(logger, audio, k, "libre.graph.audio."); ok {
|
||||
return audio
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cs3ResourceToDriveItemImageFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Image {
|
||||
k := res.GetArbitraryMetadata().GetMetadata()
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var image = &libregraph.Image{}
|
||||
if ok := unmarshalStringMap(logger, image, k, "libre.graph.image."); ok {
|
||||
return image
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cs3ResourceToDriveItemLocationFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.GeoCoordinates {
|
||||
k := res.GetArbitraryMetadata().GetMetadata()
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var location = &libregraph.GeoCoordinates{}
|
||||
if ok := unmarshalStringMap(logger, location, k, "libre.graph.location."); ok {
|
||||
return location
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cs3ResourceToDriveItemPhotoFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Photo {
|
||||
k := res.GetArbitraryMetadata().GetMetadata()
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var photo = &libregraph.Photo{}
|
||||
if ok := unmarshalStringMap(logger, photo, k, "libre.graph.photo."); ok {
|
||||
return photo
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFieldName(structField reflect.StructField) string {
|
||||
tag := structField.Tag.Get("json")
|
||||
if tag == "" {
|
||||
return structField.Name
|
||||
}
|
||||
|
||||
return strings.Split(tag, ",")[0]
|
||||
}
|
||||
|
||||
func unmarshalStringMap(logger *log.Logger, out any, flatMap map[string]string, prefix string) bool {
|
||||
nonEmpty := false
|
||||
obj := reflect.ValueOf(out).Elem()
|
||||
timeKind := reflect.TypeOf(&time.Time{}).Elem().Kind()
|
||||
for i := 0; i < obj.NumField(); i++ {
|
||||
field := obj.Field(i)
|
||||
structField := obj.Type().Field(i)
|
||||
mapKey := prefix + getFieldName(structField)
|
||||
|
||||
if value, ok := flatMap[mapKey]; ok {
|
||||
if field.Kind() == reflect.Ptr {
|
||||
newValue := reflect.New(field.Type().Elem())
|
||||
var tmp any
|
||||
var err error
|
||||
switch t := newValue.Type().Elem().Kind(); t {
|
||||
case reflect.String:
|
||||
tmp = value
|
||||
case reflect.Int32:
|
||||
tmp, err = strconv.ParseInt(value, 10, 32)
|
||||
case reflect.Int64:
|
||||
tmp, err = strconv.ParseInt(value, 10, 64)
|
||||
case reflect.Float32:
|
||||
tmp, err = strconv.ParseFloat(value, 32)
|
||||
case reflect.Float64:
|
||||
tmp, err = strconv.ParseFloat(value, 64)
|
||||
case reflect.Bool:
|
||||
tmp, err = strconv.ParseBool(value)
|
||||
case timeKind:
|
||||
tmp, err = time.Parse(time.RFC3339, value)
|
||||
default:
|
||||
err = errors.New("unsupported type")
|
||||
logger.Error().Err(err).Str("type", t.String()).Str("mapKey", mapKey).Msg("target field type for value of mapKey is not supported")
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("mapKey", mapKey).Msg("unmarshalling failed")
|
||||
continue
|
||||
}
|
||||
newValue.Elem().Set(reflect.ValueOf(tmp).Convert(field.Type().Elem()))
|
||||
field.Set(newValue)
|
||||
nonEmpty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nonEmpty
|
||||
}
|
||||
|
||||
func cs3ResourceToRemoteItem(res *storageprovider.ResourceInfo) (*libregraph.RemoteItem, error) {
|
||||
size := new(int64)
|
||||
*size = int64(res.GetSize()) // TODO lurking overflow: make size of libregraph drive item use uint64
|
||||
|
||||
remoteItem := &libregraph.RemoteItem{
|
||||
Id: libregraph.PtrString(storagespace.FormatResourceID(res.GetId())),
|
||||
Size: size,
|
||||
}
|
||||
|
||||
if res.GetPath() != "" {
|
||||
remoteItem.Path = libregraph.PtrString(path.Clean(res.GetPath()))
|
||||
}
|
||||
if res.GetEtag() != "" {
|
||||
remoteItem.ETag = &res.Etag
|
||||
}
|
||||
if res.GetMtime() != nil {
|
||||
lastModified := cs3TimestampToTime(res.GetMtime())
|
||||
remoteItem.LastModifiedDateTime = &lastModified
|
||||
}
|
||||
if res.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_FILE && res.GetMimeType() != "" {
|
||||
// We cannot use a libregraph.File here because the openapi codegenerator autodetects 'File' as a go type ...
|
||||
remoteItem.File = &libregraph.OpenGraphFile{
|
||||
MimeType: &res.MimeType,
|
||||
}
|
||||
}
|
||||
if res.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER {
|
||||
remoteItem.Folder = &libregraph.Folder{}
|
||||
}
|
||||
if res.GetSpace() != nil && res.GetSpace().GetRoot() != nil {
|
||||
remoteItem.RootId = libregraph.PtrString(storagespace.FormatResourceID(res.GetSpace().GetRoot()))
|
||||
grantSpaceAlias := utils.ReadPlainFromOpaque(res.GetSpace().GetOpaque(), "spaceAlias")
|
||||
if grantSpaceAlias != "" {
|
||||
remoteItem.DriveAlias = libregraph.PtrString(grantSpaceAlias)
|
||||
}
|
||||
}
|
||||
return remoteItem, nil
|
||||
}
|
||||
|
||||
func (g Graph) getPathForResource(ctx context.Context, id storageprovider.ResourceId) (string, error) {
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
res, err := gatewayClient.GetPath(ctx, &storageprovider.GetPathRequest{ResourceId: &id})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
|
||||
return "", fmt.Errorf("could not stat %v: %s", id, res.GetStatus().GetMessage())
|
||||
}
|
||||
return res.GetPath(), err
|
||||
}
|
||||
|
||||
// getSpecialDriveItems reads properties from the opaque and transforms them into driveItems
|
||||
func (g Graph) getSpecialDriveItems(ctx context.Context, baseURL *url.URL, space *storageprovider.StorageSpace) []libregraph.DriveItem {
|
||||
if space.GetRoot().GetStorageId() == utils.ShareStorageProviderID {
|
||||
return nil // no point in stating the ShareStorageProvider
|
||||
}
|
||||
if space.GetOpaque() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
imageNode := utils.ReadPlainFromOpaque(space.GetOpaque(), SpaceImageSpecialFolderName)
|
||||
readmeNode := utils.ReadPlainFromOpaque(space.GetOpaque(), ReadmeSpecialFolderName)
|
||||
|
||||
cachekey := spaceRootStatKey(space.GetRoot(), imageNode, readmeNode)
|
||||
// if the root is older or equal to our cache we can reuse the cached extended spaces properties
|
||||
if entry := g.specialDriveItemsCache.Get(cachekey); entry != nil {
|
||||
if cached, ok := entry.Value().(specialDriveItemEntry); ok {
|
||||
if cached.rootMtime != nil && space.GetMtime() != nil {
|
||||
// beware, LaterTS does not handle equalness. it returns t1 if t1 > t2, else t2, so a >= check looks like this
|
||||
if utils.LaterTS(space.GetMtime(), cached.rootMtime) == cached.rootMtime {
|
||||
return cached.specialDriveItems
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var spaceItems []libregraph.DriveItem
|
||||
var err error
|
||||
doCache := true
|
||||
|
||||
spaceItems, err = g.fetchSpecialDriveItem(ctx, spaceItems, SpaceImageSpecialFolderName, imageNode, space, baseURL)
|
||||
if err != nil {
|
||||
doCache = false
|
||||
g.logger.Debug().Err(err).Str("ID", imageNode).Msg("Could not get space image")
|
||||
}
|
||||
spaceItems, err = g.fetchSpecialDriveItem(ctx, spaceItems, ReadmeSpecialFolderName, readmeNode, space, baseURL)
|
||||
if err != nil {
|
||||
doCache = false
|
||||
g.logger.Debug().Err(err).Str("ID", imageNode).Msg("Could not get space readme")
|
||||
}
|
||||
|
||||
// cache properties
|
||||
spacePropertiesEntry := specialDriveItemEntry{
|
||||
specialDriveItems: spaceItems,
|
||||
rootMtime: space.GetMtime(),
|
||||
}
|
||||
|
||||
if doCache {
|
||||
g.specialDriveItemsCache.Set(cachekey, spacePropertiesEntry, time.Duration(g.config.Spaces.ExtendedSpacePropertiesCacheTTL))
|
||||
}
|
||||
|
||||
return spaceItems
|
||||
}
|
||||
|
||||
func (g Graph) fetchSpecialDriveItem(ctx context.Context, spaceItems []libregraph.DriveItem, itemName string, itemNode string, space *storageprovider.StorageSpace, baseURL *url.URL) ([]libregraph.DriveItem, error) {
|
||||
var ref *storageprovider.Reference
|
||||
if itemNode != "" {
|
||||
rid, _ := storagespace.ParseID(itemNode)
|
||||
|
||||
rid.StorageId = space.GetRoot().GetStorageId()
|
||||
ref = &storageprovider.Reference{
|
||||
ResourceId: &rid,
|
||||
}
|
||||
spaceItem, err := g.getSpecialDriveItem(ctx, ref, itemName, baseURL, space)
|
||||
if err != nil {
|
||||
return spaceItems, err
|
||||
}
|
||||
if spaceItem != nil {
|
||||
spaceItems = append(spaceItems, *spaceItem)
|
||||
}
|
||||
}
|
||||
return spaceItems, nil
|
||||
}
|
||||
|
||||
// generates a space root stat cache key used to detect changes in a space
|
||||
// takes into account the special nodes because changing metadata does not affect the etag / mtime
|
||||
func spaceRootStatKey(id *storageprovider.ResourceId, imagenode, readmeNode string) string {
|
||||
if id == nil {
|
||||
return ""
|
||||
}
|
||||
shakeHash := sha3.NewShake256()
|
||||
_, _ = shakeHash.Write([]byte(id.GetStorageId()))
|
||||
_, _ = shakeHash.Write([]byte(id.GetSpaceId()))
|
||||
_, _ = shakeHash.Write([]byte(id.GetOpaqueId()))
|
||||
_, _ = shakeHash.Write([]byte(imagenode))
|
||||
_, _ = shakeHash.Write([]byte(readmeNode))
|
||||
h := make([]byte, 64)
|
||||
_, _ = shakeHash.Read(h)
|
||||
return hex.EncodeToString(h)
|
||||
}
|
||||
|
||||
type specialDriveItemEntry struct {
|
||||
specialDriveItems []libregraph.DriveItem
|
||||
rootMtime *types.Timestamp
|
||||
}
|
||||
|
||||
func (g Graph) getSpecialDriveItem(ctx context.Context, ref *storageprovider.Reference, itemName string, baseURL *url.URL, space *storageprovider.StorageSpace) (*libregraph.DriveItem, error) {
|
||||
var spaceItem *libregraph.DriveItem
|
||||
if ref.GetResourceId().GetSpaceId() == "" && ref.GetResourceId().GetOpaqueId() == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// FIXME we should send a fieldmask 'path' and return it as the Path property to save an additional call to the storage.
|
||||
// To do that we need to align the useg of ResourceInfo.Name vs ResourceInfo.Path. By default, only the name should be set
|
||||
// and Path should always be relative to the space root OR the resource the current user can access ...
|
||||
spaceItem, err := g.getDriveItem(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
itemPath := ref.GetPath()
|
||||
if itemPath == "" {
|
||||
// lookup by id
|
||||
itemPath, err = g.getPathForResource(ctx, *ref.GetResourceId())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
spaceItem.SpecialFolder = &libregraph.SpecialFolder{Name: libregraph.PtrString(itemName)}
|
||||
webdavURL := *baseURL
|
||||
webdavURL.Path = path.Join(webdavURL.Path, space.GetId().GetOpaqueId(), itemPath)
|
||||
spaceItem.WebDavUrl = libregraph.PtrString(webdavURL.String())
|
||||
|
||||
return spaceItem, nil
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/services/graph/mocks"
|
||||
"github.com/qsfera/server/services/graph/pkg/config"
|
||||
"github.com/qsfera/server/services/graph/pkg/config/defaults"
|
||||
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
|
||||
service "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
)
|
||||
|
||||
type itemsList struct {
|
||||
Value []*libregraph.DriveItem
|
||||
}
|
||||
|
||||
var _ = Describe("Driveitems", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
eventsPublisher mocks.Publisher
|
||||
identityBackend *identitymocks.Backend
|
||||
|
||||
rr *httptest.ResponseRecorder
|
||||
|
||||
newGroup *libregraph.Group
|
||||
|
||||
currentUser = &userpb.User{
|
||||
Id: &userpb.UserId{
|
||||
OpaqueId: "user",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
|
||||
identityBackend = &identitymocks.Backend{}
|
||||
newGroup = libregraph.NewGroup()
|
||||
newGroup.SetMembersodataBind([]string{"/users/user1"})
|
||||
newGroup.SetId("group1")
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
ctx = context.Background()
|
||||
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
cfg.Commons = &shared.Commons{}
|
||||
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
|
||||
|
||||
var err error
|
||||
svc, err = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
service.WithIdentityBackend(identityBackend),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("GetRootDriveChildren", func() {
|
||||
It("handles ListStorageSpaces not found", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewNotFound(ctx, "not found"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
|
||||
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
|
||||
svc.GetRootDriveChildren(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("handles ListStorageSpaces error", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewInternal(ctx, "internal error"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
|
||||
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
|
||||
svc.GetRootDriveChildren(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
|
||||
It("handles ListContainer not found", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
|
||||
}, nil)
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewNotFound(ctx, "not found"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
|
||||
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
|
||||
svc.GetRootDriveChildren(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("handles ListContainer permission denied", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
|
||||
}, nil)
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewPermissionDenied(ctx, errors.New("denied"), "denied"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
|
||||
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
|
||||
svc.GetRootDriveChildren(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("handles ListContainer error", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
|
||||
}, nil)
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewInternal(ctx, "internal"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
|
||||
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
|
||||
svc.GetRootDriveChildren(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
|
||||
It("succeeds", func() {
|
||||
mtime := time.Now()
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
|
||||
}, nil)
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Infos: []*provider.ResourceInfo{
|
||||
{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
|
||||
Etag: "etag",
|
||||
Mtime: utils.TimeToTS(mtime),
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
|
||||
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
|
||||
svc.GetRootDriveChildren(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := itemsList{}
|
||||
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
Expect(res.Value[0].GetLastModifiedDateTime().Equal(mtime)).To(BeTrue())
|
||||
Expect(res.Value[0].GetETag()).To(Equal("etag"))
|
||||
Expect(res.Value[0].GetId()).To(Equal("storageid$spaceid!opaqueid"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetDriveItemChildren", func() {
|
||||
It("handles ListContainer not found", func() {
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewNotFound(ctx, "not found"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("driveID", "storageid$spaceid")
|
||||
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.GetDriveItemChildren(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("handles ListContainer permission denied as not found", func() {
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewPermissionDenied(ctx, errors.New("denied"), "denied"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("driveID", "storageid$spaceid")
|
||||
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.GetDriveItemChildren(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("handles ListContainer error", func() {
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewInternal(ctx, "internal"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("driveID", "storageid$spaceid")
|
||||
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.GetDriveItemChildren(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
|
||||
Context("it succeeds", func() {
|
||||
var (
|
||||
r *http.Request
|
||||
mtime = time.Now()
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("driveID", "storageid$spaceid")
|
||||
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
})
|
||||
|
||||
assertItemsList := func(length int) itemsList {
|
||||
svc.GetDriveItemChildren(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := itemsList{}
|
||||
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(res.Value)).To(Equal(length))
|
||||
Expect(res.Value[0].GetLastModifiedDateTime().Equal(mtime)).To(BeTrue())
|
||||
Expect(res.Value[0].GetETag()).To(Equal("etag"))
|
||||
Expect(res.Value[0].GetId()).To(Equal("storageid$spaceid!opaqueid"))
|
||||
Expect(res.Value[0].GetId()).To(Equal("storageid$spaceid!opaqueid"))
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
It("returns a generic file", func() {
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Infos: []*provider.ResourceInfo{
|
||||
{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
|
||||
Etag: "etag",
|
||||
Mtime: utils.TimeToTS(mtime),
|
||||
ArbitraryMetadata: nil,
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
res := assertItemsList(1)
|
||||
Expect(res.Value[0].Audio).To(BeNil())
|
||||
Expect(res.Value[0].Location).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the audio facet if metadata is available", func() {
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Infos: []*provider.ResourceInfo{
|
||||
{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
|
||||
Etag: "etag",
|
||||
Mtime: utils.TimeToTS(mtime),
|
||||
MimeType: "audio/mpeg",
|
||||
ArbitraryMetadata: &provider.ArbitraryMetadata{
|
||||
Metadata: map[string]string{
|
||||
"libre.graph.audio.album": "Some Album",
|
||||
"libre.graph.audio.albumArtist": "Some AlbumArtist",
|
||||
"libre.graph.audio.artist": "Some Artist",
|
||||
"libre.graph.audio.bitrate": "192",
|
||||
"libre.graph.audio.composers": "Some Composers",
|
||||
"libre.graph.audio.copyright": "Some Copyright",
|
||||
"libre.graph.audio.disc": "2",
|
||||
"libre.graph.audio.discCount": "5",
|
||||
"libre.graph.audio.duration": "225000",
|
||||
"libre.graph.audio.genre": "Some Genre",
|
||||
"libre.graph.audio.hasDrm": "false",
|
||||
"libre.graph.audio.isVariableBitrate": "true",
|
||||
"libre.graph.audio.title": "Some Title",
|
||||
"libre.graph.audio.track": "6",
|
||||
"libre.graph.audio.trackCount": "9",
|
||||
"libre.graph.audio.year": "1994",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
res := assertItemsList(1)
|
||||
audio := res.Value[0].Audio
|
||||
|
||||
Expect(audio).ToNot(BeNil())
|
||||
Expect(audio.Album).To(Equal(libregraph.PtrString("Some Album")))
|
||||
Expect(audio.AlbumArtist).To(Equal(libregraph.PtrString("Some AlbumArtist")))
|
||||
Expect(audio.Artist).To(Equal(libregraph.PtrString("Some Artist")))
|
||||
Expect(audio.Bitrate).To(Equal(libregraph.PtrInt64(192)))
|
||||
Expect(audio.Composers).To(Equal(libregraph.PtrString("Some Composers")))
|
||||
Expect(audio.Copyright).To(Equal(libregraph.PtrString("Some Copyright")))
|
||||
Expect(audio.Disc).To(Equal(libregraph.PtrInt32(2)))
|
||||
Expect(audio.DiscCount).To(Equal(libregraph.PtrInt32(5)))
|
||||
Expect(audio.Duration).To(Equal(libregraph.PtrInt64(225000)))
|
||||
Expect(audio.Genre).To(Equal(libregraph.PtrString("Some Genre")))
|
||||
Expect(audio.HasDrm).To(Equal(libregraph.PtrBool(false)))
|
||||
Expect(audio.IsVariableBitrate).To(Equal(libregraph.PtrBool(true)))
|
||||
Expect(audio.Title).To(Equal(libregraph.PtrString("Some Title")))
|
||||
Expect(audio.Track).To(Equal(libregraph.PtrInt32(6)))
|
||||
Expect(audio.TrackCount).To(Equal(libregraph.PtrInt32(9)))
|
||||
Expect(audio.Year).To(Equal(libregraph.PtrInt32(1994)))
|
||||
})
|
||||
|
||||
It("returns the location facet if metadata is available", func() {
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Infos: []*provider.ResourceInfo{
|
||||
{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
|
||||
Etag: "etag",
|
||||
Mtime: utils.TimeToTS(mtime),
|
||||
MimeType: "image/jpeg",
|
||||
ArbitraryMetadata: &provider.ArbitraryMetadata{
|
||||
Metadata: map[string]string{
|
||||
"libre.graph.location.altitude": "1047.7",
|
||||
"libre.graph.location.latitude": "49.48675890884328",
|
||||
"libre.graph.location.longitude": "11.103870357204285",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
res := assertItemsList(1)
|
||||
location := res.Value[0].Location
|
||||
|
||||
Expect(location).ToNot(BeNil())
|
||||
Expect(location.Altitude).To(Equal(libregraph.PtrFloat64(1047.7)))
|
||||
Expect(location.Latitude).To(Equal(libregraph.PtrFloat64(49.48675890884328)))
|
||||
Expect(location.Longitude).To(Equal(libregraph.PtrFloat64(11.103870357204285)))
|
||||
})
|
||||
|
||||
It("returns the image facet if metadata is available", func() {
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Infos: []*provider.ResourceInfo{
|
||||
{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
|
||||
Etag: "etag",
|
||||
Mtime: utils.TimeToTS(mtime),
|
||||
MimeType: "image/jpeg",
|
||||
ArbitraryMetadata: &provider.ArbitraryMetadata{
|
||||
Metadata: map[string]string{
|
||||
"libre.graph.image.width": "1234",
|
||||
"libre.graph.image.height": "987",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
res := assertItemsList(1)
|
||||
image := res.Value[0].Image
|
||||
|
||||
Expect(image).ToNot(BeNil())
|
||||
Expect(image.Width).To(Equal(libregraph.PtrInt32(1234)))
|
||||
Expect(image.Height).To(Equal(libregraph.PtrInt32(987)))
|
||||
})
|
||||
|
||||
It("returns the photo facet if metadata is available", func() {
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Infos: []*provider.ResourceInfo{
|
||||
{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
|
||||
Etag: "etag",
|
||||
Mtime: utils.TimeToTS(mtime),
|
||||
MimeType: "image/jpeg",
|
||||
ArbitraryMetadata: &provider.ArbitraryMetadata{
|
||||
Metadata: map[string]string{
|
||||
"libre.graph.photo.cameraMake": "Canon",
|
||||
"libre.graph.photo.cameraModel": "Cannon EOS 5D Mark III",
|
||||
"libre.graph.photo.exposureDenominator": "100",
|
||||
"libre.graph.photo.exposureNumerator": "1",
|
||||
"libre.graph.photo.fNumber": "1.8",
|
||||
"libre.graph.photo.focalLength": "50",
|
||||
"libre.graph.photo.iso": "400",
|
||||
"libre.graph.photo.orientation": "1",
|
||||
"libre.graph.photo.takenDateTime": "2018-01-01T12:34:56Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
res := assertItemsList(1)
|
||||
photo := res.Value[0].Photo
|
||||
|
||||
Expect(photo).ToNot(BeNil())
|
||||
Expect(photo.CameraMake).To(Equal(libregraph.PtrString("Canon")))
|
||||
Expect(photo.CameraModel).To(Equal(libregraph.PtrString("Cannon EOS 5D Mark III")))
|
||||
Expect(photo.ExposureDenominator).To(Equal(libregraph.PtrFloat64(100)))
|
||||
Expect(photo.ExposureNumerator).To(Equal(libregraph.PtrFloat64(1)))
|
||||
Expect(photo.FNumber).To(Equal(libregraph.PtrFloat64(1.8)))
|
||||
Expect(photo.FocalLength).To(Equal(libregraph.PtrFloat64(50)))
|
||||
Expect(photo.Iso).To(Equal(libregraph.PtrInt32(400)))
|
||||
Expect(photo.Orientation).To(Equal(libregraph.PtrInt32(1)))
|
||||
Expect(photo.TakenDateTime).To(Equal(libregraph.PtrTime(time.Date(2018, 1, 1, 12, 34, 56, 0, time.UTC))))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
)
|
||||
|
||||
func TestCS3ResourceToDriveItemPopulatesWebUrl(t *testing.T) {
|
||||
logger := log.NewLogger()
|
||||
res := &provider.ResourceInfo{
|
||||
Id: &provider.ResourceId{
|
||||
StorageId: "storage-1",
|
||||
SpaceId: "space-1",
|
||||
OpaqueId: "item-1",
|
||||
},
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER,
|
||||
}
|
||||
|
||||
t.Run("public base URL without path", func(t *testing.T) {
|
||||
base, err := url.Parse("https://example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
item, err := cs3ResourceToDriveItem(&logger, base, res)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item.WebUrl)
|
||||
assert.Equal(t, "https://example.com/f/storage-1$space-1%21item-1", *item.WebUrl)
|
||||
})
|
||||
|
||||
t.Run("public base URL with path prefix", func(t *testing.T) {
|
||||
base, err := url.Parse("https://example.com/cloud")
|
||||
require.NoError(t, err)
|
||||
|
||||
item, err := cs3ResourceToDriveItem(&logger, base, res)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item.WebUrl)
|
||||
assert.Equal(t, "https://example.com/cloud/f/storage-1$space-1%21item-1", *item.WebUrl)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type sortTest struct {
|
||||
Drives []*libregraph.Drive
|
||||
Query godata.GoDataRequest
|
||||
DrivesSorted []*libregraph.Drive
|
||||
}
|
||||
|
||||
var time1 = time.Date(2022, 02, 02, 15, 00, 00, 00, time.UTC)
|
||||
var time2 = time.Date(2022, 02, 03, 15, 00, 00, 00, time.UTC)
|
||||
var time3, time5, time6 *time.Time
|
||||
var time4 = time.Date(2022, 02, 05, 15, 00, 00, 00, time.UTC)
|
||||
var drives = []*libregraph.Drive{
|
||||
drive("3", "project", "Admin", time3),
|
||||
drive("1", "project", "Alan", &time1),
|
||||
drive("2", "project", "Mary", &time2),
|
||||
drive("4", "project", "Margaret", &time4),
|
||||
}
|
||||
var drivesLong = append(drives, []*libregraph.Drive{
|
||||
drive("5", "project", "bob", time5),
|
||||
drive("6", "project", "alice", time6),
|
||||
}...)
|
||||
|
||||
var sortTests = []sortTest{
|
||||
{
|
||||
Drives: drives,
|
||||
Query: godata.GoDataRequest{
|
||||
Query: &godata.GoDataQuery{
|
||||
OrderBy: &godata.GoDataOrderByQuery{
|
||||
OrderByItems: []*godata.OrderByItem{
|
||||
{Field: &godata.Token{Value: "name"}, Order: "asc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DrivesSorted: []*libregraph.Drive{
|
||||
drive("3", "project", "Admin", time3),
|
||||
drive("1", "project", "Alan", &time1),
|
||||
drive("4", "project", "Margaret", &time4),
|
||||
drive("2", "project", "Mary", &time2),
|
||||
},
|
||||
},
|
||||
{
|
||||
Drives: drives,
|
||||
Query: godata.GoDataRequest{
|
||||
Query: &godata.GoDataQuery{
|
||||
OrderBy: &godata.GoDataOrderByQuery{
|
||||
OrderByItems: []*godata.OrderByItem{
|
||||
{Field: &godata.Token{Value: "name"}, Order: _sortDescending},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DrivesSorted: []*libregraph.Drive{
|
||||
drive("2", "project", "Mary", &time2),
|
||||
drive("4", "project", "Margaret", &time4),
|
||||
drive("1", "project", "Alan", &time1),
|
||||
drive("3", "project", "Admin", time3),
|
||||
},
|
||||
},
|
||||
{
|
||||
Drives: drivesLong,
|
||||
Query: godata.GoDataRequest{
|
||||
Query: &godata.GoDataQuery{
|
||||
OrderBy: &godata.GoDataOrderByQuery{
|
||||
OrderByItems: []*godata.OrderByItem{
|
||||
{Field: &godata.Token{Value: "lastModifiedDateTime"}, Order: "asc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DrivesSorted: []*libregraph.Drive{
|
||||
drive("3", "project", "Admin", time3),
|
||||
drive("6", "project", "alice", time6),
|
||||
drive("5", "project", "bob", time5),
|
||||
drive("1", "project", "Alan", &time1),
|
||||
drive("2", "project", "Mary", &time2),
|
||||
drive("4", "project", "Margaret", &time4),
|
||||
},
|
||||
},
|
||||
{
|
||||
Drives: drivesLong,
|
||||
Query: godata.GoDataRequest{
|
||||
Query: &godata.GoDataQuery{
|
||||
OrderBy: &godata.GoDataOrderByQuery{
|
||||
OrderByItems: []*godata.OrderByItem{
|
||||
{Field: &godata.Token{Value: "lastModifiedDateTime"}, Order: _sortDescending},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DrivesSorted: []*libregraph.Drive{
|
||||
drive("4", "project", "Margaret", &time4),
|
||||
drive("2", "project", "Mary", &time2),
|
||||
drive("1", "project", "Alan", &time1),
|
||||
drive("5", "project", "bob", time5),
|
||||
drive("6", "project", "alice", time6),
|
||||
drive("3", "project", "Admin", time3),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func drive(ID string, dType string, name string, lastModified *time.Time) *libregraph.Drive {
|
||||
return &libregraph.Drive{Id: libregraph.PtrString(ID), DriveType: libregraph.PtrString(dType), Name: name, LastModifiedDateTime: lastModified, Quota: &libregraph.Quota{}}
|
||||
}
|
||||
|
||||
// TestSort tests the available orderby queries
|
||||
func TestSort(t *testing.T) {
|
||||
for _, test := range sortTests {
|
||||
sorted, err := sortSpaces(&test.Query, test.Drives)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, test.DrivesSorted, sorted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortNameNatural verifies that sorting drives by name uses a natural
|
||||
// (numeric-aware) order, so "Project 10" sorts after "Project 2".
|
||||
func TestSortNameNatural(t *testing.T) {
|
||||
input := []*libregraph.Drive{
|
||||
drive("a", "project", "Project 10", nil),
|
||||
drive("b", "project", "Project 2", nil),
|
||||
drive("c", "project", "Project 1", nil),
|
||||
drive("d", "project", "Project 10a", nil),
|
||||
}
|
||||
want := []string{"Project 1", "Project 2", "Project 10", "Project 10a"}
|
||||
query := godata.GoDataRequest{
|
||||
Query: &godata.GoDataQuery{
|
||||
OrderBy: &godata.GoDataOrderByQuery{
|
||||
OrderByItems: []*godata.OrderByItem{
|
||||
{Field: &godata.Token{Value: "name"}, Order: "asc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sorted, err := sortSpaces(&query, input)
|
||||
require.NoError(t, err)
|
||||
got := make([]string, 0, len(sorted))
|
||||
for _, d := range sorted {
|
||||
got = append(got, d.GetName())
|
||||
}
|
||||
assert.Equal(t, want, got)
|
||||
|
||||
// Descending must invert the natural order.
|
||||
query.Query.OrderBy.OrderByItems[0].Order = _sortDescending
|
||||
sortedDesc, err := sortSpaces(&query, input)
|
||||
require.NoError(t, err)
|
||||
gotDesc := make([]string, 0, len(sortedDesc))
|
||||
for _, d := range sortedDesc {
|
||||
gotDesc = append(gotDesc, d.GetName())
|
||||
}
|
||||
assert.Equal(t, []string{"Project 10a", "Project 10", "Project 2", "Project 1"}, gotDesc)
|
||||
}
|
||||
|
||||
func TestSpaceNameValidation(t *testing.T) {
|
||||
// set max length
|
||||
_maxSpaceNameLength = 10
|
||||
|
||||
testCases := []struct {
|
||||
Alias string
|
||||
SpaceName string
|
||||
ExpectedError error
|
||||
}{
|
||||
{"Happy Path", "Space", nil},
|
||||
{"Just not too Long", "abcdefghij", nil},
|
||||
{"Too Long", "abcdefghijk", ErrNameTooLong},
|
||||
{"Empty", "", ErrNameEmpty},
|
||||
{"Contains /", "Space/", ErrForbiddenCharacter},
|
||||
{`Contains \`, `Space\`, ErrForbiddenCharacter},
|
||||
{`Contains \\`, `Space\\`, ErrForbiddenCharacter},
|
||||
{"Contains .", "Space.", ErrForbiddenCharacter},
|
||||
{"Contains :", "Space:", ErrForbiddenCharacter},
|
||||
{"Contains ?", "Sp?ace", ErrForbiddenCharacter},
|
||||
{"Contains *", "Spa*ce", ErrForbiddenCharacter},
|
||||
{`Contains "`, `"Space"`, ErrForbiddenCharacter},
|
||||
{`Contains >`, `Sp>ce`, ErrForbiddenCharacter},
|
||||
{`Contains <`, `S<pce`, ErrForbiddenCharacter},
|
||||
{`Contains |`, `S|p|e`, ErrForbiddenCharacter},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
err := validateSpaceName(tc.SpaceName)
|
||||
require.Equal(t, tc.ExpectedError, err, tc.Alias)
|
||||
}
|
||||
|
||||
// set max length back to protect other tests
|
||||
_maxSpaceNameLength = 255
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
)
|
||||
|
||||
// GetEducationClasses implements the Service interface.
|
||||
func (g Graph) GetEducationClasses(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Interface("query", r.URL.Query()).Msg("calling GetEducationClasses")
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg(
|
||||
"could not get educationClasses: query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
classes, err := g.identityEducationBackend.GetEducationClasses(r.Context())
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not get classes: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
classes, err = sortClasses(odataReq, classes)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get classes: could not sort classes according to query")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &ListResponse{Value: classes})
|
||||
}
|
||||
|
||||
// PostEducationClass implements the Service interface.
|
||||
func (g Graph) PostEducationClass(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Msg("calling post EducationClass")
|
||||
class := libregraph.NewEducationClassWithDefaults()
|
||||
err := StrictJSONUnmarshal(r.Body, class)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create education class: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := class.GetDisplayNameOk(); !ok {
|
||||
logger.Debug().Err(err).Interface("class", class).Msg("could not create class: missing required attribute")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute")
|
||||
return
|
||||
}
|
||||
|
||||
// Disallow user-supplied IDs. It's supposed to be readonly. We're either
|
||||
// generating them in the backend ourselves or rely on the Backend's
|
||||
// storage (e.g. LDAP) to provide a unique ID.
|
||||
if _, ok := class.GetIdOk(); ok {
|
||||
logger.Debug().Msg("could not create class: id is a read-only attribute")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "class id is a read-only attribute")
|
||||
return
|
||||
}
|
||||
|
||||
if class, err = g.identityEducationBackend.CreateEducationClass(r.Context(), *class); err != nil {
|
||||
logger.Debug().Interface("class", class).Msg("could not create class: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
/* TODO requires reva changes
|
||||
if class != nil && class.Id != nil {
|
||||
currentUser := revactx.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(events.EducationClassCreated{Executant: currentUser.Id, EducationClassID: *class.Id})
|
||||
}
|
||||
*/
|
||||
render.Status(r, http.StatusCreated)
|
||||
render.JSON(w, r, class)
|
||||
}
|
||||
|
||||
// PatchEducationClass implements the Service interface.
|
||||
func (g Graph) PatchEducationClass(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
classID := chi.URLParam(r, "classID")
|
||||
logger.Info().Str("classID", classID).Msg("calling patch education class")
|
||||
classID, err := url.PathUnescape(classID)
|
||||
if err != nil {
|
||||
logger.Debug().Str("id", classID).Msg("could not change class: unescaping class id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if classID == "" {
|
||||
logger.Debug().Msg("could not change class: missing class id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
|
||||
return
|
||||
}
|
||||
changes := libregraph.NewEducationClassWithDefaults()
|
||||
err = StrictJSONUnmarshal(r.Body, changes)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not change class: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var features []events.GroupFeature
|
||||
if displayName, ok := changes.GetDisplayNameOk(); ok {
|
||||
features = append(features, events.GroupFeature{Name: "displayname", Value: *displayName})
|
||||
}
|
||||
|
||||
if externalID, ok := changes.GetExternalIdOk(); ok {
|
||||
features = append(features, events.GroupFeature{Name: "externalid", Value: *externalID})
|
||||
}
|
||||
|
||||
_, err = g.identityEducationBackend.UpdateEducationClass(r.Context(), classID, *changes)
|
||||
if err != nil {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("classID", classID).
|
||||
Msg("could not update class")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if memberRefs, ok := changes.GetMembersodataBindOk(); ok {
|
||||
// The spec defines a limit of 20 members maxium per Request
|
||||
if len(memberRefs) > g.config.API.GroupMembersPatchLimit {
|
||||
logger.Debug().
|
||||
Int("number", len(memberRefs)).
|
||||
Int("limit", g.config.API.GroupMembersPatchLimit).
|
||||
Msg("could not add group members, exceeded members limit")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
|
||||
fmt.Sprintf("Request is limited to %d members", g.config.API.GroupMembersPatchLimit))
|
||||
return
|
||||
}
|
||||
memberIDs := make([]string, 0, len(memberRefs))
|
||||
for _, memberRef := range memberRefs {
|
||||
memberType, id, err := g.parseMemberRef(memberRef)
|
||||
if err != nil {
|
||||
logger.Debug().
|
||||
Str("memberref", memberRef).
|
||||
Msg("could not change class: Error parsing member@odata.bind values")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing member@odata.bind values")
|
||||
return
|
||||
}
|
||||
logger.Debug().Str("membertype", memberType).Str("memberid", id).Msg("add class member")
|
||||
// The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact"
|
||||
// we restrict this to users for now. Might add Classes as members later
|
||||
if memberType != memberTypeUsers {
|
||||
logger.Debug().
|
||||
Str("type", memberType).
|
||||
Msg("could not change class: could not add member, only user type is allowed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only user are allowed as class members")
|
||||
return
|
||||
}
|
||||
memberIDs = append(memberIDs, id)
|
||||
}
|
||||
err = g.identityBackend.AddMembersToGroup(r.Context(), classID, memberIDs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not change class: backend could not add members")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(features) > 0 {
|
||||
e := events.GroupFeatureChanged{
|
||||
GroupID: classID,
|
||||
Features: features,
|
||||
}
|
||||
|
||||
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(r.Context(), e)
|
||||
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusNoContent) // TODO StatusNoContent when prefer=minimal is used, otherwise OK and the resource in the body
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// GetEducationClass implements the Service interface.
|
||||
func (g Graph) GetEducationClass(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
classID := chi.URLParam(r, "classID")
|
||||
logger.Info().Str("classID", classID).Msg("calling get education class")
|
||||
classID, err := url.PathUnescape(classID)
|
||||
if err != nil {
|
||||
logger.Debug().Str("id", classID).Msg("could not get class: unescaping class id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
|
||||
}
|
||||
|
||||
if classID == "" {
|
||||
logger.Debug().Msg("could not get class: missing class id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
Str("id", classID).
|
||||
Interface("query", r.URL.Query()).
|
||||
Msg("calling get class on backend")
|
||||
class, err := g.identityEducationBackend.GetEducationClass(r.Context(), classID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not get class: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, class)
|
||||
}
|
||||
|
||||
// DeleteEducationClass implements the Service interface.
|
||||
func (g Graph) DeleteEducationClass(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
classID := chi.URLParam(r, "classID")
|
||||
logger.Info().Str("classID", classID).Msg("calling delete class")
|
||||
classID, err := url.PathUnescape(classID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", classID).Msg("could not delete class: unescaping class id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if classID == "" {
|
||||
logger.Debug().Msg("could not delete class: missing class id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("id", classID).Msg("calling delete class on backend")
|
||||
err = g.identityEducationBackend.DeleteEducationClass(r.Context(), classID)
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not delete class: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
/* TODO requires reva changes
|
||||
currentUser := revactx.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(events.ClassDeleted{Executant: currentUser.Id, ClassID: classID})
|
||||
*/
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// GetEducationClassMembers implements the Service interface.
|
||||
func (g Graph) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
classID := chi.URLParam(r, "classID")
|
||||
logger.Info().Str("classID", classID).Msg("calling get class members")
|
||||
classID, err := url.PathUnescape(classID)
|
||||
if err != nil {
|
||||
logger.Debug().Str("id", classID).Msg("could not get class members: unescaping class id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if classID == "" {
|
||||
logger.Debug().Msg("could not get class members: missing class id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("id", classID).Msg("calling get class members on backend")
|
||||
members, err := g.identityEducationBackend.GetEducationClassMembers(r.Context(), classID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not get class members: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, members)
|
||||
}
|
||||
|
||||
// PostEducationClassMember implements the Service interface.
|
||||
func (g Graph) PostEducationClassMember(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
|
||||
classID := chi.URLParam(r, "classID")
|
||||
logger.Info().Str("classID", classID).Msg("Calling post class member")
|
||||
classID, err := url.PathUnescape(classID)
|
||||
if err != nil {
|
||||
logger.Debug().
|
||||
Err(err).
|
||||
Str("id", classID).
|
||||
Msg("could not add member to class: unescaping class id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if classID == "" {
|
||||
logger.Debug().Msg("could not add class member: missing class id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
|
||||
return
|
||||
}
|
||||
memberRef := libregraph.NewMemberReference()
|
||||
err = StrictJSONUnmarshal(r.Body, memberRef)
|
||||
if err != nil {
|
||||
logger.Debug().
|
||||
Err(err).
|
||||
Interface("body", r.Body).
|
||||
Msg("could not add class member: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
memberRefURL, ok := memberRef.GetOdataIdOk()
|
||||
if !ok {
|
||||
logger.Debug().Msg("could not add class member: @odata.id reference is missing")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
|
||||
return
|
||||
}
|
||||
memberType, id, err := g.parseMemberRef(*memberRefURL)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not add class member: error parsing @odata.id url")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
|
||||
return
|
||||
}
|
||||
// The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact"
|
||||
// we restrict this to users for now. Might add EducationClass as members later
|
||||
if memberType != memberTypeUsers {
|
||||
logger.Debug().Str("type", memberType).Msg("could not add class member: Only users are allowed as class members")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as class members")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add member on backend")
|
||||
err = g.identityBackend.AddMembersToGroup(r.Context(), classID, []string{id})
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not add class member: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
/* TODO requires reva changes
|
||||
currentUser := revactx.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(events.EducationClassMemberAdded{Executant: currentUser.Id, EducationClassID: classID, UserID: id})
|
||||
*/
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// DeleteEducationClassMember implements the Service interface.
|
||||
func (g Graph) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
|
||||
classID := chi.URLParam(r, "classID")
|
||||
memberID := chi.URLParam(r, "memberID")
|
||||
logger.Info().Str("classID", classID).Str("memberID", memberID).Msg("calling delete class member")
|
||||
classID, err := url.PathUnescape(classID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", classID).Msg("could not delete class member: unescaping class id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if classID == "" {
|
||||
logger.Debug().Msg("could not delete class member: missing class id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
|
||||
return
|
||||
}
|
||||
|
||||
memberID, err = url.PathUnescape(memberID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", memberID).Msg("could not delete class member: unescaping member id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping member id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if memberID == "" {
|
||||
logger.Debug().Msg("could not delete class member: missing member id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id")
|
||||
return
|
||||
}
|
||||
logger.Debug().Str("classID", classID).Str("memberID", memberID).Msg("calling delete member on backend")
|
||||
err = g.identityBackend.RemoveMemberFromGroup(r.Context(), classID, memberID)
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not delete class member: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
/* TODO requires reva changes
|
||||
currentUser := revactx.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(events.EducationClassMemberRemoved{Executant: currentUser.Id, EducationClassID: classID, UserID: memberID})
|
||||
*/
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// GetEducationClassTeachers implements the Service interface.
|
||||
func (g Graph) GetEducationClassTeachers(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
classID := chi.URLParam(r, "classID")
|
||||
logger.Info().Str("classID", classID).Msg("calling get class teachers")
|
||||
classID, err := url.PathUnescape(classID)
|
||||
if err != nil {
|
||||
logger.Debug().Str("id", classID).Msg("could not get class teachers: unescaping class id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if classID == "" {
|
||||
logger.Debug().Msg("could not get class teachers: missing class id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("id", classID).Msg("calling get class teachers on backend")
|
||||
teachers, err := g.identityEducationBackend.GetEducationClassTeachers(r.Context(), classID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not get class teachers: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, teachers)
|
||||
}
|
||||
|
||||
// PostEducationClassTeacher implements the Service interface.
|
||||
func (g Graph) PostEducationClassTeacher(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
|
||||
classID := chi.URLParam(r, "classID")
|
||||
logger.Info().Str("classID", classID).Msg("Calling post class teacher")
|
||||
classID, err := url.PathUnescape(classID)
|
||||
if err != nil {
|
||||
logger.Debug().
|
||||
Err(err).
|
||||
Str("id", classID).
|
||||
Msg("could not add teacher to class: unescaping class id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if classID == "" {
|
||||
logger.Debug().Msg("could not add class teacher: missing class id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
|
||||
return
|
||||
}
|
||||
memberRef := libregraph.NewMemberReference()
|
||||
err = StrictJSONUnmarshal(r.Body, memberRef)
|
||||
if err != nil {
|
||||
logger.Debug().
|
||||
Err(err).
|
||||
Interface("body", r.Body).
|
||||
Msg("could not add class teacher: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
memberRefURL, ok := memberRef.GetOdataIdOk()
|
||||
if !ok {
|
||||
logger.Debug().Msg("could not add class teacher: @odata.id reference is missing")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
|
||||
return
|
||||
}
|
||||
memberType, id, err := g.parseMemberRef(*memberRefURL)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not add class teacher: error parsing @odata.id url")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
|
||||
return
|
||||
}
|
||||
// The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact"
|
||||
// we restrict this to users for now. Might add EducationClass as members later
|
||||
if memberType != memberTypeUsers {
|
||||
logger.Debug().Str("type", memberType).Msg("could not add class member: Only users are allowed as class teachers")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as class teachers")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add teacher on backend")
|
||||
err = g.identityEducationBackend.AddTeacherToEducationClass(r.Context(), classID, id)
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not add class teacher: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
/* TODO requires reva changes
|
||||
currentUser := revactx.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(events.EducationClassTeacherAdded{Executant: currentUser.Id, EducationClassID: classID, UserID: id})
|
||||
*/
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// DeleteEducationClassTeacher implements the Service interface.
|
||||
func (g Graph) DeleteEducationClassTeacher(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
|
||||
classID := chi.URLParam(r, "classID")
|
||||
teacherID := chi.URLParam(r, "teacherID")
|
||||
logger.Info().Str("classID", classID).Str("teacherID", teacherID).Msg("calling delete class teacher")
|
||||
classID, err := url.PathUnescape(classID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", classID).Msg("could not delete class teacher: unescaping class id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if classID == "" {
|
||||
logger.Debug().Msg("could not delete class teacher: missing class id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
|
||||
return
|
||||
}
|
||||
|
||||
teacherID, err = url.PathUnescape(teacherID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", teacherID).Msg("could not delete class teacher: unescaping teacher id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping teacher id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if teacherID == "" {
|
||||
logger.Debug().Msg("could not delete class teacher: missing teacher id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing teacher id")
|
||||
return
|
||||
}
|
||||
logger.Debug().Str("classID", classID).Str("teacherID", teacherID).Msg("calling delete teacher on backend")
|
||||
err = g.identityEducationBackend.RemoveTeacherFromEducationClass(r.Context(), classID, teacherID)
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not delete class teacher: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
/* TODO requires reva changes
|
||||
currentUser := revactx.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(events.EducationClassTeacherRemoved{Executant: currentUser.Id, EducationClassID: classID, UserID: teacherID})
|
||||
*/
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
func sortClasses(req *godata.GoDataRequest, classes []*libregraph.EducationClass) ([]*libregraph.EducationClass, error) {
|
||||
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
|
||||
return classes, nil
|
||||
}
|
||||
var less func(i, j int) bool
|
||||
|
||||
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
|
||||
case displayNameAttr:
|
||||
less = func(i, j int) bool {
|
||||
return strings.ToLower(classes[i].GetDisplayName()) < strings.ToLower(classes[j].GetDisplayName())
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
|
||||
}
|
||||
|
||||
if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending {
|
||||
sort.Slice(classes, reverse(less))
|
||||
} else {
|
||||
sort.Slice(classes, less)
|
||||
}
|
||||
|
||||
return classes, nil
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/services/graph/mocks"
|
||||
"github.com/qsfera/server/services/graph/pkg/config"
|
||||
"github.com/qsfera/server/services/graph/pkg/config/defaults"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
|
||||
service "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
)
|
||||
|
||||
var _ = Describe("EducationClass", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
eventsPublisher mocks.Publisher
|
||||
identityBackend *identitymocks.Backend
|
||||
identityEducationBackend *identitymocks.EducationBackend
|
||||
|
||||
rr *httptest.ResponseRecorder
|
||||
|
||||
newClass *libregraph.EducationClass
|
||||
currentUser = &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "user",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
|
||||
identityEducationBackend = &identitymocks.EducationBackend{}
|
||||
identityBackend = &identitymocks.Backend{}
|
||||
newClass = libregraph.NewEducationClass("math", "course")
|
||||
newClass.SetMembersodataBind([]string{"/users/user1"})
|
||||
newClass.SetId("math")
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
ctx = context.Background()
|
||||
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
cfg.Commons = &shared.Commons{}
|
||||
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
|
||||
|
||||
var err error
|
||||
svc, err = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
service.WithIdentityBackend(identityBackend),
|
||||
service.WithIdentityEducationBackend(identityEducationBackend),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("GetEducationClasses", func() {
|
||||
It("handles invalid ODATA parameters", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes?§foo=bar", nil)
|
||||
svc.GetEducationClasses(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles invalid sorting queries", func() {
|
||||
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{newClass}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes?$orderby=invalid", nil)
|
||||
svc.GetEducationClasses(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
odataerr := libregraph.OdataError{}
|
||||
err = json.Unmarshal(data, &odataerr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(odataerr.Error.Code).To(Equal("invalidRequest"))
|
||||
})
|
||||
|
||||
It("handles unknown backend errors", func() {
|
||||
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return(nil, errors.New("failed"))
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
|
||||
svc.GetEducationClasses(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
odataerr := libregraph.OdataError{}
|
||||
err = json.Unmarshal(data, &odataerr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(odataerr.Error.Code).To(Equal("generalException"))
|
||||
})
|
||||
|
||||
It("handles backend errors", func() {
|
||||
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
|
||||
svc.GetEducationClasses(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
odataerr := libregraph.OdataError{}
|
||||
err = json.Unmarshal(data, &odataerr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(odataerr.Error.Code).To(Equal("accessDenied"))
|
||||
})
|
||||
|
||||
It("renders an empty list of classes", func() {
|
||||
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
|
||||
svc.GetEducationClasses(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := service.ListResponse{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Value).To(Equal([]any{}))
|
||||
})
|
||||
|
||||
It("renders a list of classes", func() {
|
||||
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{newClass}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
|
||||
svc.GetEducationClasses(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := groupList{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
Expect(res.Value[0].GetId()).To(Equal("math"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetEducationClass", func() {
|
||||
It("handles missing or empty class id", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
|
||||
svc.GetEducationClass(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
|
||||
r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", "")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
|
||||
svc.GetEducationClass(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
Context("with an existing class", func() {
|
||||
BeforeEach(func() {
|
||||
identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
|
||||
})
|
||||
|
||||
It("gets the class", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/"+*newClass.Id, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
|
||||
|
||||
svc.GetEducationClass(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PostEducationClass", func() {
|
||||
It("handles invalid body", func() {
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBufferString("{invalid"))
|
||||
|
||||
svc.PostEducationClass(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles missing display name", func() {
|
||||
newClass = libregraph.NewEducationClassWithDefaults()
|
||||
newClass.SetId("disallowed")
|
||||
newClass.SetMembersodataBind([]string{"/non-users/user"})
|
||||
newClassJson, err := json.Marshal(newClass)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBuffer(newClassJson))
|
||||
|
||||
svc.PostEducationClass(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("disallows group create ids", func() {
|
||||
newClass = libregraph.NewEducationClassWithDefaults()
|
||||
newClass.SetId("disallowed")
|
||||
newClass.SetDisplayName("New Class")
|
||||
newClass.SetMembersodataBind([]string{"/non-users/user"})
|
||||
newClassJson, err := json.Marshal(newClass)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBuffer(newClassJson))
|
||||
|
||||
svc.PostEducationClass(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("creates the Class", func() {
|
||||
newClass = libregraph.NewEducationClassWithDefaults()
|
||||
newClass.SetDisplayName("New Class")
|
||||
newClassJson, err := json.Marshal(newClass)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
identityEducationBackend.On("CreateEducationClass", mock.Anything, mock.Anything).Return(newClass, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/class/", bytes.NewBuffer(newClassJson))
|
||||
|
||||
svc.PostEducationClass(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusCreated))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PatchEducationClass", func() {
|
||||
It("handles invalid body", func() {
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes/", bytes.NewBufferString("{invalid"))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchEducationClass(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles missing or empty group id", func() {
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
|
||||
svc.PatchEducationClass(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
|
||||
r = httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", "")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchEducationClass(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
Context("with an existing group", func() {
|
||||
BeforeEach(func() {
|
||||
identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
|
||||
identityEducationBackend.On("UpdateEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
|
||||
})
|
||||
|
||||
It("fails when the number of users is exceeded - spec says 20 max", func() {
|
||||
updatedClass := libregraph.NewEducationClassWithDefaults()
|
||||
updatedClass.SetDisplayName("class updated")
|
||||
updatedClass.SetMembersodataBind([]string{
|
||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
|
||||
"19", "20", "21",
|
||||
})
|
||||
updatedClassJson, err := json.Marshal(updatedClass)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchEducationClass(rr, r)
|
||||
|
||||
resp, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(string(resp)).To(ContainSubstring("Request is limited to 20"))
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("succeeds when the number of users is over 20 but the limit is raised to 21", func() {
|
||||
updatedClass := libregraph.NewEducationClassWithDefaults()
|
||||
updatedClass.SetDisplayName("group1 updated")
|
||||
updatedClass.SetMembersodataBind([]string{
|
||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
|
||||
"19", "20", "21",
|
||||
})
|
||||
updatedClassJson, err := json.Marshal(updatedClass)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
cfg.API.GroupMembersPatchLimit = 21
|
||||
|
||||
svc, err = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
service.WithIdentityBackend(identityBackend),
|
||||
service.WithIdentityEducationBackend(identityEducationBackend),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchEducationClass(rr, r)
|
||||
|
||||
resp, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(string(resp)).To(ContainSubstring("Error parsing member@odata.bind values"))
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails on invalid user refs", func() {
|
||||
updatedClass := libregraph.NewEducationClassWithDefaults()
|
||||
updatedClass.SetDisplayName("class updated")
|
||||
updatedClass.SetMembersodataBind([]string{"invalid"})
|
||||
updatedClassJson, err := json.Marshal(updatedClass)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchEducationClass(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails when the adding non-users users", func() {
|
||||
updatedClass := libregraph.NewEducationClassWithDefaults()
|
||||
updatedClass.SetDisplayName("group1 updated")
|
||||
updatedClass.SetMembersodataBind([]string{"/non-users/user1"})
|
||||
updatedClassJson, err := json.Marshal(updatedClass)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchEducationClass(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("adds members to the class", func() {
|
||||
identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
updatedClass := libregraph.NewEducationClassWithDefaults()
|
||||
updatedClass.SetDisplayName("Class updated")
|
||||
updatedClass.SetMembersodataBind([]string{"/users/user1"})
|
||||
updatedClassJson, err := json.Marshal(updatedClass)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchEducationClass(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteEducationClass", func() {
|
||||
Context("with an existing EducationClass", func() {
|
||||
BeforeEach(func() {
|
||||
identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
|
||||
})
|
||||
})
|
||||
|
||||
It("deletes the EducationClass", func() {
|
||||
identityEducationBackend.On("DeleteEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationClass(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationClass", 1)
|
||||
// eventsPublisher.AssertNumberOfCalls(GinkgoT(), "Publish", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetEducationClassMembers", func() {
|
||||
It("gets the list of members", func() {
|
||||
user := libregraph.NewEducationUser()
|
||||
user.SetId("user")
|
||||
identityEducationBackend.On("GetEducationClassMembers", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return([]*libregraph.EducationUser{user}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/{classID}/members", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.GetEducationClassMembers(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var members []*libregraph.EducationUser
|
||||
err = json.Unmarshal(data, &members)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(members)).To(Equal(1))
|
||||
Expect(members[0].GetId()).To(Equal("user"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PostEducationClassMember", func() {
|
||||
It("fails on invalid body", func() {
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBufferString("{invalid"))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationClassMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails on missing member refs", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationClassMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails on invalid member refs", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
member.SetOdataId("/invalidtype/user")
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationClassMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("adds a new member", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
member.SetOdataId("/users/user")
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationClassMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteEducationClassMembers", func() {
|
||||
It("handles missing or empty member id", func() {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationClassMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles missing or empty member id", func() {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("memberID", "/users/user")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationClassMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("deletes members", func() {
|
||||
identityBackend.On("RemoveMemberFromGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
rctx.URLParams.Add("memberID", "/users/user1")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationClassMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
identityBackend.AssertNumberOfCalls(GinkgoT(), "RemoveMemberFromGroup", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetEducationClassTeachers", func() {
|
||||
It("gets the list of teachers", func() {
|
||||
user := libregraph.NewEducationUser()
|
||||
user.SetId("user")
|
||||
identityEducationBackend.On("GetEducationClassTeachers", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return([]*libregraph.EducationUser{user}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/{classID}/teachers", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.GetEducationClassTeachers(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var teachers []*libregraph.EducationUser
|
||||
err = json.Unmarshal(data, &teachers)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(teachers)).To(Equal(1))
|
||||
Expect(teachers[0].GetId()).To(Equal("user"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PostEducationClassTeacher", func() {
|
||||
It("fails on invalid body", func() {
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/teachers", bytes.NewBufferString("{invalid"))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationClassTeacher(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails on missing teacher refs", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/teachers", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationClassTeacher(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails on invalid teacher refs", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
member.SetOdataId("/invalidtype/user")
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/teachers", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationClassTeacher(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("adds a new teacher", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
member.SetOdataId("/users/user")
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
identityEducationBackend.On("AddTeacherToEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/teachers", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationClassTeacher(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "AddTeacherToEducationClass", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteEducationClassTeacher", func() {
|
||||
It("handles missing or empty teacher id", func() {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/teachers/{teacherID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationClassTeacher(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles missing or empty teacher id", func() {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/teachers/{teacherID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("teacherID", "/users/user")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationClassTeacher(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("deletes teacher", func() {
|
||||
identityEducationBackend.On("RemoveTeacherFromEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/teachers/{teacherID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("classID", *newClass.Id)
|
||||
rctx.URLParams.Add("teacherID", "/users/user1")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationClassTeacher(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveTeacherFromEducationClass", 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,631 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
"github.com/qsfera/server/services/graph/pkg/identity"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
)
|
||||
|
||||
// GetEducationSchools implements the Service interface.
|
||||
func (g Graph) GetEducationSchools(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Interface("query", r.URL.Query()).Msg("calling get schools")
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get schools: query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
schools, err := g.getEducationSchoolsFromBackend(r.Context(), odataReq)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get schools")
|
||||
renderEqualityFilterError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
schools, err = sortEducationSchools(odataReq, schools)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get schools: could not sort schools according to query")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &ListResponse{Value: schools})
|
||||
}
|
||||
|
||||
// PostEducationSchool implements the Service interface.
|
||||
func (g Graph) PostEducationSchool(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Msg("calling post school")
|
||||
school := libregraph.NewEducationSchool()
|
||||
err := StrictJSONUnmarshal(r.Body, school)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create school: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Disallow user-supplied IDs. It's supposed to be readonly. We're either
|
||||
// generating them in the backend ourselves or rely on the Backend's
|
||||
// storage (e.g. LDAP) to provide a unique ID.
|
||||
if _, ok := school.GetIdOk(); ok {
|
||||
logger.Debug().Msg("could not create school: id is a read-only attribute")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "school id is a read-only attribute")
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := school.GetDisplayNameOk(); !ok {
|
||||
logger.Debug().Interface("school", school).Msg("could not create school: missing required attribute")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute")
|
||||
return
|
||||
}
|
||||
|
||||
// validate terminationDate attribute, needs to be "far enough" in the future, terminationDate can be nil (means
|
||||
// termination date is to be deleted
|
||||
if terminationDate, ok := school.GetTerminationDateOk(); ok && terminationDate != nil {
|
||||
err = g.validateTerminationDate(*terminationDate)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if school, err = g.identityEducationBackend.CreateEducationSchool(r.Context(), *school); err != nil {
|
||||
logger.Debug().Err(err).Interface("school", school).Msg("could not create school: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
/* TODO requires reva changes
|
||||
if school != nil && school.Id != nil {
|
||||
e := events.SchoolCreated{SchoolID: *school.Id}
|
||||
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(e)
|
||||
}
|
||||
*/
|
||||
|
||||
render.Status(r, http.StatusCreated)
|
||||
render.JSON(w, r, school)
|
||||
}
|
||||
|
||||
// PatchEducationSchool implements the Service interface.
|
||||
func (g Graph) PatchEducationSchool(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
schoolID := chi.URLParam(r, "schoolID")
|
||||
logger.Info().Str("schoolID", schoolID).Msg("calling patch school")
|
||||
schoolID, err := url.PathUnescape(schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().Str("id", schoolID).Msg("could not update school: unescaping school id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if schoolID == "" {
|
||||
logger.Debug().Msg("could not update school: missing school id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
|
||||
return
|
||||
}
|
||||
|
||||
school := libregraph.NewEducationSchool()
|
||||
err = StrictJSONUnmarshal(r.Body, school)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not update school: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// validate terminationDate attribute, needs to be "far enough" in the future, terminationDate can be nil (means
|
||||
// termination date is to be deleted
|
||||
if terminationDate, ok := school.GetTerminationDateOk(); ok && terminationDate != nil {
|
||||
err = g.validateTerminationDate(*terminationDate)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if school, err = g.identityEducationBackend.UpdateEducationSchool(r.Context(), schoolID, *school); err != nil {
|
||||
logger.Debug().Err(err).Interface("school", school).Msg("could not update school: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
/* TODO requires reva changes
|
||||
e := events.SchoolFeatureChanged{SchoolID: schoolID}
|
||||
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(e)
|
||||
*/
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, school)
|
||||
}
|
||||
|
||||
// GetEducationSchool implements the Service interface.
|
||||
func (g Graph) GetEducationSchool(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
schoolID := chi.URLParam(r, "schoolID")
|
||||
logger.Info().Str("schoolID", schoolID).Msg("calling get school")
|
||||
schoolID, err := url.PathUnescape(schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().Str("id", schoolID).Msg("could not get school: unescaping school id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
|
||||
}
|
||||
|
||||
if schoolID == "" {
|
||||
logger.Debug().Msg("could not get school: missing school id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
Str("id", schoolID).
|
||||
Interface("query", r.URL.Query()).
|
||||
Msg("calling get school on backend")
|
||||
school, err := g.identityEducationBackend.GetEducationSchool(r.Context(), schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not get school: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, school)
|
||||
}
|
||||
|
||||
// DeleteEducationSchool implements the Service interface.
|
||||
func (g Graph) DeleteEducationSchool(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
schoolID := chi.URLParam(r, "schoolID")
|
||||
logger.Info().Str("schoolID", schoolID).Msg("calling delete school")
|
||||
schoolID, err := url.PathUnescape(schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school: unescaping school id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if schoolID == "" {
|
||||
logger.Debug().Msg("could not delete school: missing school id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
|
||||
return
|
||||
}
|
||||
|
||||
// Read school and check if termination date is set
|
||||
school, err := g.identityEducationBackend.GetEducationSchool(r.Context(), schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not get school: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
termination, ok := school.GetTerminationDateOk()
|
||||
if !ok {
|
||||
logger.Debug().Msg("cannot delete school: not termination date set")
|
||||
errorcode.NotAllowed.Render(w, r, http.StatusMethodNotAllowed, "no termination date set")
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().Before(*termination) {
|
||||
logger.Debug().Time("terminationDate", *termination).Msg("cannot delete school: termination date not reached")
|
||||
errorcode.NotAllowed.Render(w, r, http.StatusMethodNotAllowed, "can't delete school before termination date")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("schoolID", schoolID).Msg("Getting users of school")
|
||||
users, err := g.identityEducationBackend.GetEducationSchoolUsers(r.Context(), schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not get school users: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
logger.Debug().Str("schoolID", schoolID).Str("userID", *user.Id).Msg("calling delete member on backend")
|
||||
if err := g.identityEducationBackend.RemoveUserFromEducationSchool(r.Context(), schoolID, *user.Id); err != nil {
|
||||
if errors.Is(err, identity.ErrNotFound) {
|
||||
logger.Debug().Str("schoolID", schoolID).Str("userID", *user.Id).Msg("user not found")
|
||||
continue
|
||||
}
|
||||
logger.Debug().Err(err).Msg("could not delete school member: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
// TODO Do we need return right hear?
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug().Str("id", schoolID).Msg("calling delete school on backend")
|
||||
err = g.identityEducationBackend.DeleteEducationSchool(r.Context(), schoolID)
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not delete school: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
/* TODO requires reva changes
|
||||
e := events.SchoolDeleted{SchoolID: schoolID}
|
||||
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(e)
|
||||
*/
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// GetEducationSchoolUsers implements the Service interface.
|
||||
func (g Graph) GetEducationSchoolUsers(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
schoolID := chi.URLParam(r, "schoolID")
|
||||
logger.Info().Str("schoolID", schoolID).Msg("calling get school users")
|
||||
schoolID, err := url.PathUnescape(schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().Str("id", schoolID).Msg("could not get school users: unescaping school id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if schoolID == "" {
|
||||
logger.Debug().Msg("could not get school users: missing school id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("id", schoolID).Msg("calling get school users on backend")
|
||||
users, err := g.identityEducationBackend.GetEducationSchoolUsers(r.Context(), schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not get school users: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, users)
|
||||
}
|
||||
|
||||
// PostEducationSchoolUser implements the Service interface.
|
||||
func (g Graph) PostEducationSchoolUser(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
|
||||
schoolID := chi.URLParam(r, "schoolID")
|
||||
logger.Info().Str("schoolID", schoolID).Msg("Calling post school user")
|
||||
schoolID, err := url.PathUnescape(schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().
|
||||
Err(err).
|
||||
Str("id", schoolID).
|
||||
Msg("could not add user to school: unescaping school id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if schoolID == "" {
|
||||
logger.Debug().Msg("could not add school user: missing school id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
|
||||
return
|
||||
}
|
||||
memberRef := libregraph.NewMemberReference()
|
||||
err = StrictJSONUnmarshal(r.Body, memberRef)
|
||||
if err != nil {
|
||||
logger.Debug().
|
||||
Err(err).
|
||||
Interface("body", r.Body).
|
||||
Msg("could not add school user: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
memberRefURL, ok := memberRef.GetOdataIdOk()
|
||||
if !ok {
|
||||
logger.Debug().Msg("could not add school user: @odata.id reference is missing")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
|
||||
return
|
||||
}
|
||||
memberType, id, err := g.parseMemberRef(*memberRefURL)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not add school user: error parsing @odata.id url")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
|
||||
return
|
||||
}
|
||||
// The MS Graph spec allows "directoryObject", "user", "school" and "organizational Contact"
|
||||
// we restrict this to users for now. Might add Schools as members later
|
||||
if memberType != "users" {
|
||||
logger.Debug().Str("type", memberType).Msg("could not add school user: Only users are allowed as school members")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as school members")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add user on backend")
|
||||
err = g.identityEducationBackend.AddUsersToEducationSchool(r.Context(), schoolID, []string{id})
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not add school user: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
/* TODO requires reva changes
|
||||
e := events.SchoolMemberAdded{SchoolID: schoolID, UserID: id}
|
||||
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(e)
|
||||
*/
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// DeleteEducationSchoolUser implements the Service interface.
|
||||
func (g Graph) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
|
||||
schoolID := chi.URLParam(r, "schoolID")
|
||||
userID := chi.URLParam(r, "userID")
|
||||
logger.Info().Str("schoolID", schoolID).Str("userID", userID).Msg("calling delete school member")
|
||||
schoolID, err := url.PathUnescape(schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school member: unescaping school id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if schoolID == "" {
|
||||
logger.Debug().Msg("could not delete school member: missing school id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err = url.PathUnescape(userID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", userID).Msg("could not delete school member: unescaping member id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping member id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
logger.Debug().Msg("could not delete school member: missing member id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id")
|
||||
return
|
||||
}
|
||||
logger.Debug().Str("schoolID", schoolID).Str("userID", userID).Msg("calling delete member on backend")
|
||||
err = g.identityEducationBackend.RemoveUserFromEducationSchool(r.Context(), schoolID, userID)
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not delete school member: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
/* TODO requires reva changes
|
||||
e := events.SchoolMemberRemoved{SchoolID: schoolID, UserID: userID}
|
||||
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(e)
|
||||
*/
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// GetEducationSchoolClasses implements the Service interface.
|
||||
func (g Graph) GetEducationSchoolClasses(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
schoolID := chi.URLParam(r, "schoolID")
|
||||
logger.Info().Str("schoolID", schoolID).Msg("calling get school classes")
|
||||
schoolID, err := url.PathUnescape(schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().Str("id", schoolID).Msg("could not get school users: unescaping school id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if schoolID == "" {
|
||||
logger.Debug().Msg("could not get school users: missing school id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("id", schoolID).Msg("calling get school classes on backend")
|
||||
classes, err := g.identityEducationBackend.GetEducationSchoolClasses(r.Context(), schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not get school classes: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, classes)
|
||||
}
|
||||
|
||||
// PostEducationSchoolClass implements the Service interface.
|
||||
func (g Graph) PostEducationSchoolClass(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
|
||||
schoolID := chi.URLParam(r, "schoolID")
|
||||
logger.Info().Str("schoolID", schoolID).Msg("Calling post school class")
|
||||
schoolID, err := url.PathUnescape(schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().
|
||||
Err(err).
|
||||
Str("id", schoolID).
|
||||
Msg("could not add class to school: unescaping school id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if schoolID == "" {
|
||||
logger.Debug().Msg("could not add school class: missing school id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
|
||||
return
|
||||
}
|
||||
memberRef := libregraph.NewMemberReference()
|
||||
err = StrictJSONUnmarshal(r.Body, memberRef)
|
||||
if err != nil {
|
||||
logger.Debug().
|
||||
Err(err).
|
||||
Interface("body", r.Body).
|
||||
Msg("could not add school class: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
memberRefURL, ok := memberRef.GetOdataIdOk()
|
||||
if !ok {
|
||||
logger.Debug().Msg("could not add school class: @odata.id reference is missing")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
|
||||
return
|
||||
}
|
||||
memberType, id, err := g.parseMemberRef(*memberRefURL)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not add school class: error parsing @odata.id url")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
|
||||
return
|
||||
}
|
||||
// The MS Graph spec allows "directoryObject", "user", "school" and "organizational Contact"
|
||||
// we restrict this to users for now. Might add Schools as members later
|
||||
if memberType != "classes" {
|
||||
logger.Debug().Str("type", memberType).Msg("could not add school class: Only classes are allowed as school members")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only classes are allowed as school members")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add class on backend")
|
||||
err = g.identityEducationBackend.AddClassesToEducationSchool(r.Context(), schoolID, []string{id})
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not add school class: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
/* TODO requires reva changes
|
||||
e := events.SchoolMemberAdded{SchoolID: schoolID, UserID: id}
|
||||
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(e)
|
||||
*/
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// DeleteEducationSchoolClass implements the Service interface.
|
||||
func (g Graph) DeleteEducationSchoolClass(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
|
||||
schoolID := chi.URLParam(r, "schoolID")
|
||||
classID := chi.URLParam(r, "classID")
|
||||
logger.Info().Str("schoolID", schoolID).Str("classID", classID).Msg("calling delete school class")
|
||||
schoolID, err := url.PathUnescape(schoolID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school class: unescaping school id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if schoolID == "" {
|
||||
logger.Debug().Msg("could not delete school class: missing school id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
|
||||
return
|
||||
}
|
||||
|
||||
classID, err = url.PathUnescape(classID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", classID).Msg("could not delete school class: unescaping class id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if classID == "" {
|
||||
logger.Debug().Msg("could not delete school class: missing class id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
|
||||
return
|
||||
}
|
||||
logger.Debug().Str("schoolID", schoolID).Str("classID", classID).Msg("calling delete class on backend")
|
||||
err = g.identityEducationBackend.RemoveClassFromEducationSchool(r.Context(), schoolID, classID)
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not delete school class: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
/* TODO requires reva changes
|
||||
e := events.SchoolMemberRemoved{SchoolID: schoolID, UserID: userID}
|
||||
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(e)
|
||||
*/
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (g Graph) validateTerminationDate(terminationDate time.Time) error {
|
||||
if terminationDate.Before(time.Now()) {
|
||||
return fmt.Errorf("can not set a termination date in the past")
|
||||
}
|
||||
graceDays := g.config.Identity.LDAP.EducationConfig.SchoolTerminationGraceDays
|
||||
if graceDays != 0 {
|
||||
if terminationDate.Before(time.Now().Add(time.Duration(graceDays) * 24 * time.Hour)) {
|
||||
return fmt.Errorf("termination needs to be at least %d day(s) in the future", graceDays)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortEducationSchools(req *godata.GoDataRequest, schools []*libregraph.EducationSchool) ([]*libregraph.EducationSchool, error) {
|
||||
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
|
||||
return schools, nil
|
||||
}
|
||||
var less func(i, j int) bool
|
||||
|
||||
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
|
||||
case displayNameAttr:
|
||||
less = func(i, j int) bool {
|
||||
return strings.ToLower(schools[i].GetDisplayName()) < strings.ToLower(schools[j].GetDisplayName())
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
|
||||
}
|
||||
|
||||
if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending {
|
||||
sort.Slice(schools, reverse(less))
|
||||
} else {
|
||||
sort.Slice(schools, less)
|
||||
}
|
||||
|
||||
return schools, nil
|
||||
}
|
||||
|
||||
// getEducationSchoolsFromBackend fetches schools from the backend, applying an OData $filter if present.
|
||||
func (g Graph) getEducationSchoolsFromBackend(ctx context.Context, odataReq *godata.GoDataRequest) ([]*libregraph.EducationSchool, error) {
|
||||
if odataReq.Query.Filter != nil {
|
||||
attr, value, err := g.getEqualityFilter(ctx, odataReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.identityEducationBackend.FilterEducationSchoolsByAttribute(ctx, attr, value)
|
||||
}
|
||||
return g.identityEducationBackend.GetEducationSchools(ctx)
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/services/graph/pkg/config"
|
||||
"github.com/qsfera/server/services/graph/pkg/config/defaults"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
|
||||
service "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
)
|
||||
|
||||
type schoolList struct {
|
||||
Value []*libregraph.EducationSchool
|
||||
}
|
||||
|
||||
var _ = Describe("Schools", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
identityEducationBackend *identitymocks.EducationBackend
|
||||
|
||||
rr *httptest.ResponseRecorder
|
||||
|
||||
newSchool *libregraph.EducationSchool
|
||||
currentUser = &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "user",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
|
||||
identityEducationBackend = &identitymocks.EducationBackend{}
|
||||
newSchool = libregraph.NewEducationSchool()
|
||||
newSchool.SetId("school1")
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
ctx = context.Background()
|
||||
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
|
||||
cfg.Identity.LDAP.EducationConfig.SchoolTerminationGraceDays = 30
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
cfg.Commons = &shared.Commons{}
|
||||
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
|
||||
|
||||
var err error
|
||||
svc, err = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.WithIdentityEducationBackend(identityEducationBackend),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("GetEducationSchools", func() {
|
||||
It("handles invalid ODATA parameters", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?§foo=bar", nil)
|
||||
svc.GetEducationSchools(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles invalid sorting queries", func() {
|
||||
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{newSchool}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?$orderby=invalid", nil)
|
||||
svc.GetEducationSchools(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
odataerr := libregraph.OdataError{}
|
||||
err = json.Unmarshal(data, &odataerr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(odataerr.Error.Code).To(Equal("invalidRequest"))
|
||||
})
|
||||
|
||||
It("handles unknown backend errors", func() {
|
||||
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return(nil, errors.New("failed"))
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
|
||||
svc.GetEducationSchools(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
odataerr := libregraph.OdataError{}
|
||||
err = json.Unmarshal(data, &odataerr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(odataerr.Error.Code).To(Equal("generalException"))
|
||||
})
|
||||
|
||||
It("handles backend errors", func() {
|
||||
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
|
||||
svc.GetEducationSchools(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
odataerr := libregraph.OdataError{}
|
||||
err = json.Unmarshal(data, &odataerr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(odataerr.Error.Code).To(Equal("accessDenied"))
|
||||
})
|
||||
|
||||
It("renders an empty list of schools", func() {
|
||||
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
|
||||
svc.GetEducationSchools(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := service.ListResponse{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Value).To(Equal([]any{}))
|
||||
})
|
||||
|
||||
It("renders a list of schools", func() {
|
||||
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{newSchool}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
|
||||
svc.GetEducationSchools(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := schoolList{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
Expect(res.Value[0].GetId()).To(Equal("school1"))
|
||||
})
|
||||
|
||||
When("used with a filter", func() {
|
||||
It("fails with an unsupported filter", func() {
|
||||
svc.GetEducationSchools(rr, httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?$filter="+url.QueryEscape("displayName ne 'test'"), nil))
|
||||
Expect(rr.Code).To(Equal(http.StatusNotImplemented))
|
||||
})
|
||||
It("calls the backend with the externalId filter", func() {
|
||||
identityEducationBackend.On("FilterEducationSchoolsByAttribute", mock.Anything, "externalId", "ext1234").Return([]*libregraph.EducationSchool{newSchool}, nil)
|
||||
svc.GetEducationSchools(rr, httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?$filter="+url.QueryEscape("externalId eq 'ext1234'"), nil))
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetEducationSchool", func() {
|
||||
It("handles missing or empty school id", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
|
||||
svc.GetEducationSchool(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
|
||||
r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", "")
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
|
||||
svc.GetEducationSchool(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
Context("with an existing school", func() {
|
||||
BeforeEach(func() {
|
||||
identityEducationBackend.On("GetEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(newSchool, nil)
|
||||
})
|
||||
|
||||
It("gets the school", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/"+*newSchool.Id, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
|
||||
|
||||
svc.GetEducationSchool(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PostEducationSchool", func() {
|
||||
It("handles invalid body", func() {
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBufferString("{invalid"))
|
||||
|
||||
svc.PostEducationSchool(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles missing display name", func() {
|
||||
newSchool = libregraph.NewEducationSchool()
|
||||
newSchool.SetSchoolNumber("0034")
|
||||
newSchoolJson, err := json.Marshal(newSchool)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
|
||||
|
||||
svc.PostEducationSchool(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("disallows school create ids", func() {
|
||||
newSchool = libregraph.NewEducationSchool()
|
||||
newSchool.SetId("disallowed")
|
||||
newSchool.SetDisplayName("New School")
|
||||
newSchool.SetSchoolNumber("0034")
|
||||
newSchoolJson, err := json.Marshal(newSchool)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
|
||||
|
||||
svc.PostEducationSchool(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles backend errors", func() {
|
||||
newSchool = libregraph.NewEducationSchool()
|
||||
newSchool.SetDisplayName("New School")
|
||||
newSchool.SetSchoolNumber("0034")
|
||||
newSchoolJson, err := json.Marshal(newSchool)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
identityEducationBackend.On("CreateEducationSchool", mock.Anything, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
|
||||
|
||||
svc.PostEducationSchool(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("creates the school", func() {
|
||||
newSchool = libregraph.NewEducationSchool()
|
||||
newSchool.SetDisplayName("New School")
|
||||
newSchool.SetSchoolNumber("0034")
|
||||
newSchoolJson, err := json.Marshal(newSchool)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
identityEducationBackend.On("CreateEducationSchool", mock.Anything, mock.Anything).Return(newSchool, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
|
||||
|
||||
svc.PostEducationSchool(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusCreated))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("updating a School", func() {
|
||||
schoolUpdate := libregraph.NewEducationSchool()
|
||||
schoolUpdate.SetDisplayName("New School Name")
|
||||
schoolUpdateJson, _ := json.Marshal(schoolUpdate)
|
||||
|
||||
schoolUpdatePast := libregraph.NewEducationSchool()
|
||||
schoolUpdatePast.SetTerminationDate(time.Now().Add(-time.Hour * 1))
|
||||
schoolUpdatePastJson, _ := json.Marshal(schoolUpdatePast)
|
||||
|
||||
schoolUpdateBeforeGrace := libregraph.NewEducationSchool()
|
||||
schoolUpdateBeforeGrace.SetTerminationDate(time.Now().Add(24 * 10 * time.Hour))
|
||||
schoolUpdateBeforeGraceJson, _ := json.Marshal(schoolUpdateBeforeGrace)
|
||||
|
||||
schoolUpdatePastGrace := libregraph.NewEducationSchool()
|
||||
schoolUpdatePastGrace.SetTerminationDate(time.Now().Add(24 * 31 * time.Hour))
|
||||
schoolUpdatePastGraceJson, _ := json.Marshal(schoolUpdatePastGrace)
|
||||
|
||||
BeforeEach(func() {
|
||||
identityEducationBackend.On("UpdateEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(schoolUpdate, nil)
|
||||
})
|
||||
DescribeTable("PatchEducationSchool",
|
||||
func(schoolId string, body io.Reader, statusCode int) {
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools/", body)
|
||||
rctx := chi.NewRouteContext()
|
||||
if schoolId != "" {
|
||||
rctx.URLParams.Add("schoolID", schoolId)
|
||||
}
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchEducationSchool(rr, r)
|
||||
Expect(rr.Code).To(Equal(statusCode))
|
||||
},
|
||||
Entry("handles invalid body", "school-id", bytes.NewBufferString("{invalid"), http.StatusBadRequest),
|
||||
Entry("handles missing or empty school id", "", bytes.NewBufferString(""), http.StatusBadRequest),
|
||||
Entry("handles malformed school id", "school%id", bytes.NewBuffer(schoolUpdateJson), http.StatusBadRequest),
|
||||
Entry("updates the school", "school-id", bytes.NewBuffer(schoolUpdateJson), http.StatusOK),
|
||||
Entry("fails to set a termination date in the past", "school-id", bytes.NewBuffer(schoolUpdatePastJson), http.StatusBadRequest),
|
||||
Entry("fails to set a termination date before grace period", "school-id", bytes.NewBuffer(schoolUpdateBeforeGraceJson), http.StatusBadRequest),
|
||||
Entry("succeeds to set a termination date past the grace period", "school-id", bytes.NewBuffer(schoolUpdatePastGraceJson), http.StatusOK),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("DeleteEducationSchool", func() {
|
||||
schoolWithFutureTermination := libregraph.NewEducationSchool()
|
||||
schoolWithFutureTermination.SetId("schoolWithFutureTermination")
|
||||
schoolWithFutureTermination.SetTerminationDate(time.Now().Add(time.Hour * 24))
|
||||
|
||||
schoolWithPastTermination := libregraph.NewEducationSchool()
|
||||
schoolWithPastTermination.SetId("schoolWithPastTermination")
|
||||
schoolWithPastTermination.SetTerminationDate(time.Now().Add(-time.Hour * 24))
|
||||
|
||||
Context("with an existing school", func() {
|
||||
BeforeEach(func() {
|
||||
identityEducationBackend.On("GetEducationSchool", mock.Anything, "school1").Return(newSchool, nil)
|
||||
identityEducationBackend.On("GetEducationSchool", mock.Anything, "schoolWithFutureTermination", mock.Anything).Return(schoolWithFutureTermination, nil)
|
||||
identityEducationBackend.On("GetEducationSchool", mock.Anything, "schoolWithPastTermination", mock.Anything).Return(schoolWithPastTermination, nil)
|
||||
})
|
||||
|
||||
DescribeTable("checks terminnation date",
|
||||
func(schoolId string, statusCode int) {
|
||||
identityEducationBackend.On("DeleteEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
identityEducationBackend.On("GetEducationSchoolUsers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.EducationUser{}, nil)
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", schoolId)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationSchool(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(statusCode))
|
||||
if rr.Code == http.StatusNoContent {
|
||||
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationSchool", 1)
|
||||
} else {
|
||||
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationSchool", 0)
|
||||
}
|
||||
},
|
||||
Entry("fails when school has no termination date", "school1", http.StatusMethodNotAllowed),
|
||||
Entry("fails when school has a termination date in the future", "schoolWithFutureTermination", http.StatusMethodNotAllowed),
|
||||
Entry("succeeds when school has a termination date in the past", "schoolWithPastTermination", http.StatusNoContent),
|
||||
)
|
||||
|
||||
It("removes the users from the school", func() {
|
||||
user1 := libregraph.NewEducationUser()
|
||||
user1.SetId("user1")
|
||||
user2 := libregraph.NewEducationUser()
|
||||
user2.SetId("user2")
|
||||
identityEducationBackend.On("GetEducationSchoolUsers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.EducationUser{user1, user2}, nil)
|
||||
identityEducationBackend.On("DeleteEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
identityEducationBackend.On("RemoveUserFromEducationSchool", mock.Anything, mock.Anything, *user1.Id).Return(nil)
|
||||
identityEducationBackend.On("RemoveUserFromEducationSchool", mock.Anything, mock.Anything, *user2.Id).Return(nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", "schoolWithPastTermination")
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationSchool(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationSchool", 1)
|
||||
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveUserFromEducationSchool", 2)
|
||||
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "GetEducationSchoolUsers", 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetEducationSchoolUsers", func() {
|
||||
It("gets the list of members", func() {
|
||||
user := libregraph.NewEducationUser()
|
||||
user.SetOnPremisesSamAccountName("user")
|
||||
user.SetDisplayName("display name")
|
||||
user.SetMail("mail")
|
||||
user.SetId("user")
|
||||
identityEducationBackend.On("GetEducationSchoolUsers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.EducationUser{user}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/{schoolID}/users", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.GetEducationSchoolUsers(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var members []*libregraph.User
|
||||
err = json.Unmarshal(data, &members)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(members)).To(Equal(1))
|
||||
Expect(members[0].GetId()).To(Equal("user"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PostEducationSchoolUsers", func() {
|
||||
It("fails on invalid body", func() {
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBufferString("{invalid"))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationSchoolUser(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails on missing member refs", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationSchoolUser(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails on invalid member refs", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
member.SetOdataId("/invalidtype/user")
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationSchoolUser(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("adds a new member", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
member.SetOdataId("/users/user")
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
identityEducationBackend.On("AddUsersToEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationSchoolUser(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "AddUsersToEducationSchool", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteEducationSchoolUsers", func() {
|
||||
It("handles missing or empty member id", func() {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{userID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationSchoolUser(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
It("handles missing or empty member id", func() {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{userID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", "/users/user")
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationSchoolUser(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("deletes members", func() {
|
||||
identityEducationBackend.On("RemoveUserFromEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{userID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
rctx.URLParams.Add("userID", "/users/user1")
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationSchoolUser(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveUserFromEducationSchool", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetEducationSchoolClasses", func() {
|
||||
It("gets the list of classes", func() {
|
||||
class := libregraph.NewEducationClassWithDefaults()
|
||||
class.SetId("class")
|
||||
identityEducationBackend.On("GetEducationSchoolClasses", mock.Anything, *newSchool.Id).Return([]*libregraph.EducationClass{class}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/{schoolID}/classes", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.GetEducationSchoolClasses(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var classes []*libregraph.EducationClass
|
||||
err = json.Unmarshal(data, &classes)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(classes)).To(Equal(1))
|
||||
Expect(classes[0].GetId()).To(Equal("class"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PostEducationSchoolClasses", func() {
|
||||
It("fails on invalid body", func() {
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/classes", bytes.NewBufferString("{invalid"))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationSchoolClass(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails on missing member refs", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/classes", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationSchoolClass(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails on invalid class refs", func() {
|
||||
class := libregraph.NewMemberReference()
|
||||
class.SetOdataId("/invalidtype/class")
|
||||
data, err := json.Marshal(class)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/classesa", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationSchoolUser(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("adds a new class", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
member.SetOdataId("/classes/class")
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
identityEducationBackend.On("AddClassesToEducationSchool", mock.Anything, *newSchool.Id, []string{"class"}).Return(nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostEducationSchoolClass(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "AddClassesToEducationSchool", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteEducationSchoolClass", func() {
|
||||
It("handles missing or empty member id", func() {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/classes/{classID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationSchoolClass(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("deletes members", func() {
|
||||
identityEducationBackend.On("RemoveClassFromEducationSchool", mock.Anything, *newSchool.Id, "/classes/class1").Return(nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/classes/{classID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("schoolID", *newSchool.Id)
|
||||
rctx.URLParams.Add("classID", "/classes/class1")
|
||||
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationSchoolClass(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveClassFromEducationSchool", 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,441 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
)
|
||||
|
||||
// GetEducationUsers implements the Service interface.
|
||||
func (g Graph) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context()).With().Str("func", "GetEducationUsers").Logger()
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get education users: query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
users, err := g.getEducationUsersFromBackend(r.Context(), odataReq)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get education users")
|
||||
renderEqualityFilterError(w, r, err)
|
||||
return
|
||||
}
|
||||
users, err = sortEducationUsers(odataReq, users)
|
||||
if err != nil {
|
||||
logger.Debug().Interface("query", odataReq).Msg("error while sorting education users according to query")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &ListResponse{Value: users})
|
||||
}
|
||||
|
||||
// PostEducationUser implements the Service interface.
|
||||
func (g Graph) PostEducationUser(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Interface("body", r.Body).Msg("calling create education user")
|
||||
u := libregraph.NewEducationUser()
|
||||
err := StrictJSONUnmarshal(r.Body, u)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create education user: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Disallow user-supplied IDs. It's supposed to be readonly. We're either
|
||||
// generating them in the backend ourselves or rely on the Backend's
|
||||
// storage (e.g. LDAP) to provide a unique ID.
|
||||
if _, ok := u.GetIdOk(); ok {
|
||||
logger.Debug().Interface("user", u).Msg("could not create education user: id is a read-only attribute")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "education user id is a read-only attribute")
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := u.GetDisplayNameOk(); !ok {
|
||||
logger.Debug().Err(err).Interface("user", u).Msg("could not create education user: missing required Attribute: 'displayName'")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'displayName'")
|
||||
return
|
||||
}
|
||||
|
||||
for i, identity := range u.GetIdentities() {
|
||||
if _, ok := identity.GetIssuerOk(); !ok {
|
||||
logger.Debug().Err(err).Interface("user", u).Msgf("could not create education user: missing Attribute in 'identities' Collection Entry %d: 'issuer'", i)
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("missing Attribute in 'identities' Collection Entry %d: 'issuer'", i))
|
||||
return
|
||||
}
|
||||
if _, ok := identity.GetIssuerAssignedIdOk(); !ok {
|
||||
logger.Debug().Err(err).Interface("user", u).Msgf("could not create education user: missing Attribute in 'identities' Collection Entry %d: 'issuerAssignedId'", i)
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("missing Attribute in 'identities' Collection Entry %d: 'issuerAssignedId'", i))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if accountName, ok := u.GetOnPremisesSamAccountNameOk(); ok {
|
||||
if !g.isValidUsername(*accountName) {
|
||||
logger.Debug().Str("username", *accountName).Msg("could not create education user: username must be at least the local part of an email")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("username %s must be at least the local part of an email", *u.OnPremisesSamAccountName))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
logger.Debug().Interface("user", u).Msg("could not create education user: missing required Attribute: 'onPremisesSamAccountName'")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'onPremisesSamAccountName'")
|
||||
return
|
||||
}
|
||||
|
||||
if mail, ok := u.GetMailOk(); ok {
|
||||
if !isValidEmail(*mail) {
|
||||
logger.Debug().Str("mail", *u.Mail).Msg("could not create education user: invalid email address")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("%v is not a valid email address", *u.Mail))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if u.HasUserType() {
|
||||
if !isValidUserType(*u.UserType) {
|
||||
logger.Debug().Interface("user", u).Msg("invalid userType attribute")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid userType attribute, valid options are 'Member' or 'Guest'")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
u.SetUserType("Member")
|
||||
}
|
||||
|
||||
logger.Debug().Interface("user", u).Msg("calling create education user on backend")
|
||||
if u, err = g.identityEducationBackend.CreateEducationUser(r.Context(), *u); err != nil {
|
||||
logger.Debug().Err(err).Msg("could not create education user: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
e := events.UserCreated{UserID: *u.Id}
|
||||
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(r.Context(), e)
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, u)
|
||||
}
|
||||
|
||||
// GetEducationUser implements the Service interface.
|
||||
func (g Graph) GetEducationUser(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
userID := chi.URLParam(r, "userID")
|
||||
logger.Info().Str("userID", userID).Msg("calling get education user")
|
||||
userID, err := url.PathUnescape(userID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", userID).Msg("could not get education user: unescaping education user id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping education user id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
logger.Debug().Msg("could not get user: missing education user id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing education user id")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("id", userID).Msg("calling get education user from backend")
|
||||
user, err := g.identityEducationBackend.GetEducationUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not get education user: error fetching education user from backend")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, user)
|
||||
}
|
||||
|
||||
// DeleteEducationUser implements the Service interface.
|
||||
func (g Graph) DeleteEducationUser(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
userID := chi.URLParam(r, "userID")
|
||||
logger.Info().Str("userID", userID).Msg("calling delete education user")
|
||||
userID, err := url.PathUnescape(userID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", userID).Msg("could not delete education user: unescaping education user id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping education user id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
logger.Debug().Msg("could not delete education user: missing education user id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing education user id")
|
||||
return
|
||||
}
|
||||
logger.Debug().Str("id", userID).Msg("calling get education user on user backend")
|
||||
user, err := g.identityEducationBackend.GetEducationUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("userID", userID).Msg("failed to get education user from backend")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
e := events.UserDeleted{UserID: user.GetId()}
|
||||
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
|
||||
if currentUser.GetId().GetOpaqueId() == user.GetId() {
|
||||
logger.Debug().Msg("could not delete education user: self deletion forbidden")
|
||||
errorcode.NotAllowed.Render(w, r, http.StatusForbidden, "self deletion forbidden")
|
||||
return
|
||||
}
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
|
||||
if g.gatewaySelector != nil {
|
||||
logger.Debug().
|
||||
Str("user", user.GetId()).
|
||||
Msg("calling list spaces with user filter to fetch the personal space for deletion")
|
||||
opaque := utils.AppendPlainToOpaque(nil, "unrestricted", "T")
|
||||
f := listStorageSpacesUserFilter(user.GetId())
|
||||
client, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select next gateway client, aborting")
|
||||
return
|
||||
}
|
||||
lspr, err := client.ListStorageSpaces(r.Context(), &storageprovider.ListStorageSpacesRequest{
|
||||
Opaque: opaque,
|
||||
Filters: []*storageprovider.ListStorageSpacesRequest_Filter{f},
|
||||
})
|
||||
if err != nil {
|
||||
// transport error, log as error
|
||||
logger.Error().Err(err).Msg("could not fetch spaces: transport error")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not fetch spaces for deletion, aborting")
|
||||
return
|
||||
}
|
||||
for _, sp := range lspr.GetStorageSpaces() {
|
||||
if !(sp.SpaceType == _spaceTypePersonal && sp.Owner.Id.OpaqueId == user.GetId()) {
|
||||
continue
|
||||
}
|
||||
// TODO: check if request contains a homespace and if, check if requesting user has the privilege to
|
||||
// delete it and make sure it is not deleting its own homespace
|
||||
// needs modification of the cs3api
|
||||
|
||||
// Deleting a space a two step process (1. disabling/trashing, 2. purging)
|
||||
// Do the "disable/trash" step only if the space is not marked as trashed yet:
|
||||
if _, ok := sp.Opaque.Map[_spaceStateTrashed]; !ok {
|
||||
_, err := client.DeleteStorageSpace(r.Context(), &storageprovider.DeleteStorageSpaceRequest{
|
||||
Id: &storageprovider.StorageSpaceId{
|
||||
OpaqueId: sp.Id.OpaqueId,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not disable homespace: transport error")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not disable homespace, aborting")
|
||||
return
|
||||
}
|
||||
}
|
||||
purgeFlag := utils.AppendPlainToOpaque(nil, "purge", "")
|
||||
_, err := client.DeleteStorageSpace(r.Context(), &storageprovider.DeleteStorageSpaceRequest{
|
||||
Opaque: purgeFlag,
|
||||
Id: &storageprovider.StorageSpaceId{
|
||||
OpaqueId: sp.Id.OpaqueId,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// transport error, log as error
|
||||
logger.Error().Err(err).Msg("could not delete homespace: transport error")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not delete homespace, aborting")
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug().Str("id", user.GetId()).Msg("calling delete education user on backend")
|
||||
err = g.identityEducationBackend.DeleteEducationUser(r.Context(), user.GetId())
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not delete education user: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
g.publishEvent(r.Context(), e)
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// PatchEducationUser implements the Service Interface. Updates the specified attributes of an
|
||||
// ExistingUser
|
||||
func (g Graph) PatchEducationUser(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
nameOrID := chi.URLParam(r, "userID")
|
||||
logger.Info().Str("userID", nameOrID).Msg("calling patch education user")
|
||||
nameOrID, err := url.PathUnescape(nameOrID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", nameOrID).Msg("could not update education user: unescaping education user id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping education user id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if nameOrID == "" {
|
||||
logger.Debug().Msg("could not update education user: missing education user id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing education user id")
|
||||
return
|
||||
}
|
||||
changes := libregraph.NewEducationUser()
|
||||
err = StrictJSONUnmarshal(r.Body, changes)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not update education user: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
|
||||
fmt.Sprintf("invalid request body: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if accountName, ok := changes.GetOnPremisesSamAccountNameOk(); ok {
|
||||
if !g.isValidUsername(*accountName) {
|
||||
logger.Debug().Str("username", *accountName).Msg("could not update education user: username must be at least the local part of an email")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("username %s must be at least the local part of an email", *changes.OnPremisesSamAccountName))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var features []events.UserFeature
|
||||
if mail, ok := changes.GetMailOk(); ok {
|
||||
if !isValidEmail(*mail) {
|
||||
logger.Debug().Str("mail", *mail).Msg("could not update education user: email is not a valid email address")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
|
||||
fmt.Sprintf("'%s' is not a valid email address", *mail))
|
||||
return
|
||||
}
|
||||
features = append(features, events.UserFeature{Name: "email", Value: *mail})
|
||||
}
|
||||
|
||||
if name, ok := changes.GetDisplayNameOk(); ok {
|
||||
features = append(features, events.UserFeature{Name: "displayname", Value: *name})
|
||||
}
|
||||
|
||||
if changes.HasUserType() {
|
||||
if !isValidUserType(*changes.UserType) {
|
||||
logger.Debug().Interface("user", changes).Msg("invalid userType attribute")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid userType attribute, valid options are 'Member' or 'Guest'")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug().Str("nameid", nameOrID).Interface("changes", *changes).Msg("calling update education user on backend")
|
||||
u, err := g.identityEducationBackend.UpdateEducationUser(r.Context(), nameOrID, *changes)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", nameOrID).Msg("could not update education user: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
e := events.UserFeatureChanged{
|
||||
UserID: nameOrID,
|
||||
Features: features,
|
||||
Timestamp: utils.TSNow(),
|
||||
}
|
||||
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(r.Context(), e)
|
||||
|
||||
render.Status(r, http.StatusOK) // TODO StatusNoContent when prefer=minimal is used
|
||||
render.JSON(w, r, u)
|
||||
}
|
||||
|
||||
func sortEducationUsers(req *godata.GoDataRequest, users []*libregraph.EducationUser) ([]*libregraph.EducationUser, error) {
|
||||
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
|
||||
return users, nil
|
||||
}
|
||||
var less func(i, j int) bool
|
||||
|
||||
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
|
||||
case displayNameAttr:
|
||||
less = func(i, j int) bool {
|
||||
return strings.ToLower(users[i].GetDisplayName()) < strings.ToLower(users[j].GetDisplayName())
|
||||
}
|
||||
case "mail":
|
||||
less = func(i, j int) bool {
|
||||
return strings.ToLower(users[i].GetMail()) < strings.ToLower(users[j].GetMail())
|
||||
}
|
||||
case "onPremisesSamAccountName":
|
||||
less = func(i, j int) bool {
|
||||
return strings.ToLower(users[i].GetOnPremisesSamAccountName()) < strings.ToLower(users[j].GetOnPremisesSamAccountName())
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
|
||||
}
|
||||
|
||||
if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending {
|
||||
sort.Slice(users, reverse(less))
|
||||
} else {
|
||||
sort.Slice(users, less)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// getEducationUsersFromBackend fetches users from the backend, applying an OData $filter if present.
|
||||
func (g Graph) getEducationUsersFromBackend(ctx context.Context, odataReq *godata.GoDataRequest) ([]*libregraph.EducationUser, error) {
|
||||
if odataReq.Query.Filter != nil {
|
||||
attr, value, err := g.getEqualityFilter(ctx, odataReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.identityEducationBackend.FilterEducationUsersByAttribute(ctx, attr, value)
|
||||
}
|
||||
return g.identityEducationBackend.GetEducationUsers(ctx)
|
||||
}
|
||||
|
||||
func (g Graph) getEqualityFilter(ctx context.Context, req *godata.GoDataRequest) (string, string, error) {
|
||||
logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
|
||||
root := req.Query.Filter.Tree
|
||||
|
||||
if root.Token.Type != godata.ExpressionTokenLogical {
|
||||
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
|
||||
return "", "", unsupportedFilterError()
|
||||
}
|
||||
if root.Token.Value != "eq" {
|
||||
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
|
||||
return "", "", unsupportedFilterError()
|
||||
}
|
||||
if len(root.Children) != 2 {
|
||||
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
|
||||
return "", "", unsupportedFilterError()
|
||||
}
|
||||
if root.Children[0].Token.Type != godata.ExpressionTokenLiteral || root.Children[1].Token.Type != godata.ExpressionTokenString {
|
||||
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
|
||||
return "", "", unsupportedFilterError()
|
||||
}
|
||||
|
||||
// unquote
|
||||
value := strings.Trim(root.Children[1].Token.Value, "'")
|
||||
return root.Children[0].Token.Value, value, nil
|
||||
}
|
||||
|
||||
// renderEqualityFilterError writes the appropriate HTTP error response for errors returned by getEqualityFilter.
|
||||
func renderEqualityFilterError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var errcode errorcode.Error
|
||||
var godataerr *godata.GoDataError
|
||||
switch {
|
||||
case errors.As(err, &errcode):
|
||||
errcode.Render(w, r)
|
||||
case errors.As(err, &godataerr):
|
||||
errorcode.GeneralException.Render(w, r, godataerr.ResponseCode, err.Error())
|
||||
default:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/graph/mocks"
|
||||
"github.com/qsfera/server/services/graph/pkg/config"
|
||||
"github.com/qsfera/server/services/graph/pkg/config/defaults"
|
||||
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
|
||||
service "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
)
|
||||
|
||||
type educationUserList struct {
|
||||
Value []*libregraph.EducationUser
|
||||
}
|
||||
|
||||
var _ = Describe("EducationUsers", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
eventsPublisher mocks.Publisher
|
||||
roleService *mocks.RoleService
|
||||
identityEducationBackend *identitymocks.EducationBackend
|
||||
|
||||
rr *httptest.ResponseRecorder
|
||||
|
||||
currentUser = &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "user",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
|
||||
identityEducationBackend = &identitymocks.EducationBackend{}
|
||||
roleService = &mocks.RoleService{}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
ctx = context.Background()
|
||||
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
cfg.Commons = &shared.Commons{}
|
||||
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
|
||||
|
||||
var err error
|
||||
svc, err = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
service.WithIdentityEducationBackend(identityEducationBackend),
|
||||
service.WithRoleService(roleService),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("GetEducationUsers", func() {
|
||||
It("handles invalid requests", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$invalid=true", nil)
|
||||
svc.GetEducationUsers(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("lists the users", func() {
|
||||
user := &libregraph.EducationUser{}
|
||||
user.SetId("user1")
|
||||
users := []*libregraph.EducationUser{user}
|
||||
|
||||
identityEducationBackend.On("GetEducationUsers", mock.Anything, mock.Anything, mock.Anything).Return(users, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users", nil)
|
||||
svc.GetEducationUsers(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := educationUserList{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
Expect(res.Value[0].GetId()).To(Equal("user1"))
|
||||
})
|
||||
|
||||
It("sorts", func() {
|
||||
user := &libregraph.EducationUser{}
|
||||
user.SetId("user1")
|
||||
user.SetMail("z@example.com")
|
||||
user.SetDisplayName("9")
|
||||
user.SetOnPremisesSamAccountName("9")
|
||||
user2 := &libregraph.EducationUser{}
|
||||
user2.SetId("user2")
|
||||
user2.SetMail("a@example.com")
|
||||
user2.SetDisplayName("1")
|
||||
user2.SetOnPremisesSamAccountName("1")
|
||||
users := []*libregraph.EducationUser{user, user2}
|
||||
|
||||
identityEducationBackend.On("GetEducationUsers", mock.Anything, mock.Anything, mock.Anything).Return(users, nil)
|
||||
|
||||
getUsers := func(path string) []*libregraph.EducationUser {
|
||||
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
svc.GetEducationUsers(rec, r)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rec.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := educationUserList{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return res.Value
|
||||
}
|
||||
|
||||
unsorted := getUsers("/graph/v1.0/education/users")
|
||||
Expect(len(unsorted)).To(Equal(2))
|
||||
Expect(unsorted[0].GetId()).To(Equal("user1"))
|
||||
Expect(unsorted[1].GetId()).To(Equal("user2"))
|
||||
|
||||
byMail := getUsers("/graph/v1.0/education/users?$orderby=mail")
|
||||
Expect(len(byMail)).To(Equal(2))
|
||||
Expect(byMail[0].GetId()).To(Equal("user2"))
|
||||
Expect(byMail[1].GetId()).To(Equal("user1"))
|
||||
byMail = getUsers("/graph/v1.0/education/users?$orderby=mail%20asc")
|
||||
Expect(len(byMail)).To(Equal(2))
|
||||
Expect(byMail[0].GetId()).To(Equal("user2"))
|
||||
Expect(byMail[1].GetId()).To(Equal("user1"))
|
||||
byMail = getUsers("/graph/v1.0/education/users?$orderby=mail%20desc")
|
||||
Expect(len(byMail)).To(Equal(2))
|
||||
Expect(byMail[0].GetId()).To(Equal("user1"))
|
||||
Expect(byMail[1].GetId()).To(Equal("user2"))
|
||||
|
||||
byDisplayName := getUsers("/graph/v1.0/education/users?$orderby=displayName")
|
||||
Expect(len(byDisplayName)).To(Equal(2))
|
||||
Expect(byDisplayName[0].GetId()).To(Equal("user2"))
|
||||
Expect(byDisplayName[1].GetId()).To(Equal("user1"))
|
||||
byDisplayName = getUsers("/graph/v1.0/education/users?$orderby=displayName%20asc")
|
||||
Expect(len(byDisplayName)).To(Equal(2))
|
||||
Expect(byDisplayName[0].GetId()).To(Equal("user2"))
|
||||
Expect(byDisplayName[1].GetId()).To(Equal("user1"))
|
||||
byDisplayName = getUsers("/graph/v1.0/education/users?$orderby=displayName%20desc")
|
||||
Expect(len(byDisplayName)).To(Equal(2))
|
||||
Expect(byDisplayName[0].GetId()).To(Equal("user1"))
|
||||
Expect(byDisplayName[1].GetId()).To(Equal("user2"))
|
||||
|
||||
byOnPremisesSamAccountName := getUsers("/graph/v1.0/education/users?$orderby=onPremisesSamAccountName")
|
||||
Expect(len(byOnPremisesSamAccountName)).To(Equal(2))
|
||||
Expect(byOnPremisesSamAccountName[0].GetId()).To(Equal("user2"))
|
||||
Expect(byOnPremisesSamAccountName[1].GetId()).To(Equal("user1"))
|
||||
byOnPremisesSamAccountName = getUsers("/graph/v1.0/education/users?$orderby=onPremisesSamAccountName%20asc")
|
||||
Expect(len(byOnPremisesSamAccountName)).To(Equal(2))
|
||||
Expect(byOnPremisesSamAccountName[0].GetId()).To(Equal("user2"))
|
||||
Expect(byOnPremisesSamAccountName[1].GetId()).To(Equal("user1"))
|
||||
byOnPremisesSamAccountName = getUsers("/graph/v1.0/education/users?$orderby=onPremisesSamAccountName%20desc")
|
||||
Expect(len(byOnPremisesSamAccountName)).To(Equal(2))
|
||||
Expect(byOnPremisesSamAccountName[0].GetId()).To(Equal("user1"))
|
||||
Expect(byOnPremisesSamAccountName[1].GetId()).To(Equal("user2"))
|
||||
|
||||
// Handles invalid sort field
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$orderby=invalid", nil)
|
||||
svc.GetEducationUsers(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
When("used with a filter", func() {
|
||||
It("fails with an unsupported filter ", func() {
|
||||
svc.GetEducationUsers(rr, httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$filter="+url.QueryEscape("displayName ne 'test'"), nil))
|
||||
Expect(rr.Code).To(Equal(http.StatusNotImplemented))
|
||||
})
|
||||
It("calls the backend with the filter", func() {
|
||||
identityEducationBackend.On("FilterEducationUsersByAttribute", mock.Anything, "externalId", "id1234").Return([]*libregraph.EducationUser{}, nil)
|
||||
svc.GetEducationUsers(rr, httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$filter="+url.QueryEscape("externalId eq 'id1234'"), nil))
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetEducationUser", func() {
|
||||
It("handles missing userids", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users", nil)
|
||||
svc.GetEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("gets the user", func() {
|
||||
user := &libregraph.EducationUser{}
|
||||
user.SetId("user1")
|
||||
|
||||
identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", *user.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.GetEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
responseUser := &libregraph.EducationUser{}
|
||||
err = json.Unmarshal(data, &responseUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(responseUser.GetId()).To(Equal("user1"))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Describe("PostEducationUser", func() {
|
||||
var (
|
||||
user *libregraph.EducationUser
|
||||
|
||||
assertHandleBadAttributes = func(user *libregraph.EducationUser) {
|
||||
userJson, err := json.Marshal(user)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson))
|
||||
svc.PostEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
identity := libregraph.ObjectIdentity{}
|
||||
identity.SetIssuer("issu.er")
|
||||
identity.SetIssuerAssignedId("our-user.1")
|
||||
|
||||
user = &libregraph.EducationUser{}
|
||||
user.SetDisplayName("Display Name")
|
||||
user.SetOnPremisesSamAccountName("user")
|
||||
user.SetMail("user@example.com")
|
||||
user.SetAccountEnabled(true)
|
||||
user.SetIdentities([]libregraph.ObjectIdentity{identity})
|
||||
user.SetPrimaryRole("student")
|
||||
})
|
||||
|
||||
It("handles invalid bodies", func() {
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", nil)
|
||||
svc.PostEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles missing display names", func() {
|
||||
user.DisplayName = nil
|
||||
assertHandleBadAttributes(user)
|
||||
|
||||
})
|
||||
|
||||
It("handles missing OnPremisesSamAccountName", func() {
|
||||
user.OnPremisesSamAccountName = nil
|
||||
assertHandleBadAttributes(user)
|
||||
|
||||
user.SetOnPremisesSamAccountName("")
|
||||
assertHandleBadAttributes(user)
|
||||
})
|
||||
|
||||
It("handles bad Mails", func() {
|
||||
user.SetMail("not-a-mail-address")
|
||||
assertHandleBadAttributes(user)
|
||||
})
|
||||
|
||||
It("handles set Ids - they are read-only", func() {
|
||||
user.SetId("/users/user")
|
||||
assertHandleBadAttributes(user)
|
||||
})
|
||||
|
||||
It("creates a user", func() {
|
||||
roleService.On("AssignRoleToUser", mock.Anything, mock.Anything).Return(&settingssvc.AssignRoleToUserResponse{}, nil)
|
||||
identityEducationBackend.On("CreateEducationUser", mock.Anything, mock.Anything).Return(func(ctx context.Context, user libregraph.EducationUser) *libregraph.EducationUser {
|
||||
user.SetId("/users/user")
|
||||
return &user
|
||||
}, nil)
|
||||
userJson, err := json.Marshal(user)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson))
|
||||
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
|
||||
svc.PostEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
createdUser := libregraph.EducationUser{}
|
||||
err = json.Unmarshal(data, &createdUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(createdUser.GetUserType()).To(Equal("Member"))
|
||||
})
|
||||
|
||||
It("creates a guest user", func() {
|
||||
roleService.On("AssignRoleToUser", mock.Anything, mock.Anything).Return(&settingssvc.AssignRoleToUserResponse{}, nil)
|
||||
identityEducationBackend.On("CreateEducationUser", mock.Anything, mock.Anything).Return(func(ctx context.Context, user libregraph.EducationUser) *libregraph.EducationUser {
|
||||
user.SetId("/users/user")
|
||||
return &user
|
||||
}, nil)
|
||||
|
||||
user.SetUserType("Guest")
|
||||
userJson, err := json.Marshal(user)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson))
|
||||
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
|
||||
svc.PostEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
createdUser := libregraph.EducationUser{}
|
||||
err = json.Unmarshal(data, &createdUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(createdUser.GetUserType()).To(Equal("Guest"))
|
||||
})
|
||||
|
||||
It("creates a member user", func() {
|
||||
roleService.On("AssignRoleToUser", mock.Anything, mock.Anything).Return(&settingssvc.AssignRoleToUserResponse{}, nil)
|
||||
identityEducationBackend.On("CreateEducationUser", mock.Anything, mock.Anything).Return(func(ctx context.Context, user libregraph.EducationUser) *libregraph.EducationUser {
|
||||
user.SetId("/users/user")
|
||||
return &user
|
||||
}, nil)
|
||||
|
||||
user.SetUserType("Member")
|
||||
userJson, err := json.Marshal(user)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson))
|
||||
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
|
||||
svc.PostEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
createdUser := libregraph.EducationUser{}
|
||||
err = json.Unmarshal(data, &createdUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(createdUser.GetUserType()).To(Equal("Member"))
|
||||
})
|
||||
|
||||
It("creates a user without email", func() {
|
||||
roleService.On("AssignRoleToUser", mock.Anything, mock.Anything).Return(&settingssvc.AssignRoleToUserResponse{}, nil)
|
||||
identityEducationBackend.On("CreateEducationUser", mock.Anything, mock.Anything).Return(func(ctx context.Context, user libregraph.EducationUser) *libregraph.EducationUser {
|
||||
user.SetId("/users/user")
|
||||
return &user
|
||||
}, nil)
|
||||
user.Mail = nil
|
||||
userJson, err := json.Marshal(user)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson))
|
||||
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
|
||||
svc.PostEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
createdUser := libregraph.EducationUser{}
|
||||
err = json.Unmarshal(data, &createdUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(createdUser.GetMail()).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteEducationUser", func() {
|
||||
It("handles missing userids", func() {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/users/{userid}", nil)
|
||||
svc.DeleteEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("prevents a user from deleting themselves", func() {
|
||||
lu := libregraph.EducationUser{}
|
||||
lu.SetId(currentUser.Id.OpaqueId)
|
||||
identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(&lu, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/users/{userid}", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", currentUser.Id.OpaqueId)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("deletes a user from deleting themselves", func() {
|
||||
otheruser := &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "otheruser",
|
||||
},
|
||||
}
|
||||
|
||||
lu := libregraph.EducationUser{}
|
||||
lu.SetId(otheruser.Id.OpaqueId)
|
||||
identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(&lu, nil)
|
||||
identityEducationBackend.On("DeleteEducationUser", mock.Anything, mock.Anything).Return(nil)
|
||||
gatewayClient.On("DeleteStorageSpace", mock.Anything, mock.Anything).Return(&provider.DeleteStorageSpaceResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
}, nil)
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{
|
||||
{
|
||||
Opaque: &typesv1beta1.Opaque{},
|
||||
Id: &provider.StorageSpaceId{OpaqueId: "drive1"},
|
||||
Root: &provider.ResourceId{SpaceId: "space", OpaqueId: "space"},
|
||||
SpaceType: "personal",
|
||||
Owner: otheruser,
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/users/{userid}", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", lu.GetId())
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
gatewayClient.AssertNumberOfCalls(GinkgoT(), "DeleteStorageSpace", 2) // 2 calls for the home space. first trash, then purge
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PatchEducationUser", func() {
|
||||
var (
|
||||
user *libregraph.EducationUser
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
user = &libregraph.EducationUser{}
|
||||
user.SetDisplayName("Display Name")
|
||||
user.SetOnPremisesSamAccountName("user")
|
||||
user.SetMail("user@example.com")
|
||||
user.SetId("/users/user")
|
||||
user.SetAccountEnabled(true)
|
||||
|
||||
identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(&user, nil)
|
||||
})
|
||||
|
||||
It("handles missing userids", func() {
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/users/{userid}", nil)
|
||||
svc.PatchEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles invalid bodies", func() {
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", user.GetId())
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles invalid email", func() {
|
||||
user.SetMail("invalid")
|
||||
data, err := json.Marshal(user)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", user.GetId())
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles invalid userType", func() {
|
||||
user.SetUserType("Clown")
|
||||
data, err := json.Marshal(user)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", user.GetId())
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("updates attributes", func() {
|
||||
identityEducationBackend.On("UpdateEducationUser", mock.Anything, user.GetId(), mock.Anything).Return(user, nil)
|
||||
|
||||
user.SetDisplayName("New Display Name")
|
||||
user.SetAccountEnabled(false)
|
||||
data, err := json.Marshal(user)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", user.GetId())
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchEducationUser(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err = io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
updatedUser := libregraph.EducationUser{}
|
||||
err = json.Unmarshal(data, &updatedUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updatedUser.GetDisplayName()).To(Equal("New Display Name"))
|
||||
Expect(updatedUser.GetAccountEnabled()).To(Equal(false))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
package svc
|
||||
|
||||
var (
|
||||
CS3ReceivedShareToLibreGraphPermissions = cs3ReceivedShareToLibreGraphPermissions
|
||||
)
|
||||
@@ -0,0 +1,173 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
revaCtx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
)
|
||||
|
||||
const _favoriteLabel = "favorite"
|
||||
|
||||
// FollowDriveItem marks a drive item as favorite.
|
||||
func (g Graph) FollowDriveItem(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
itemID, err := parseIDParam(r, "itemID")
|
||||
if err != nil {
|
||||
g.logger.Debug().Err(err).Msg("could not parse itemID")
|
||||
return
|
||||
}
|
||||
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusServiceUnavailable, "could not select next gateway client")
|
||||
return
|
||||
}
|
||||
|
||||
ref := &provider.Reference{
|
||||
ResourceId: &itemID,
|
||||
}
|
||||
|
||||
u, ok := revactx.ContextGetUser(ctx)
|
||||
if !ok {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusUnauthorized, "User not found in context")
|
||||
return
|
||||
}
|
||||
|
||||
statReq := &provider.StatRequest{
|
||||
Ref: ref,
|
||||
}
|
||||
statRes, err := gatewayClient.Stat(ctx, statReq)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not stat resource")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not stat resource")
|
||||
return
|
||||
}
|
||||
switch statRes.GetStatus().GetCode() {
|
||||
case rpc.Code_CODE_OK:
|
||||
// continue
|
||||
case rpc.Code_CODE_NOT_FOUND:
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "resource not found")
|
||||
return
|
||||
default:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not stat resource")
|
||||
return
|
||||
}
|
||||
|
||||
req := &provider.AddLabelRequest{
|
||||
Ref: ref,
|
||||
UserId: u.Id,
|
||||
Label: _favoriteLabel,
|
||||
}
|
||||
|
||||
res, err := gatewayClient.AddLabel(ctx, req)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not add label")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not add label")
|
||||
return
|
||||
}
|
||||
|
||||
if res.Status.Code != rpc.Code_CODE_OK {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not add label")
|
||||
return
|
||||
}
|
||||
|
||||
if g.eventsPublisher != nil {
|
||||
ev := events.LabelAdded{
|
||||
Ref: &provider.Reference{
|
||||
ResourceId: &itemID,
|
||||
Path: ".",
|
||||
},
|
||||
Label: _favoriteLabel,
|
||||
UserID: u.Id,
|
||||
Executant: revaCtx.ContextMustGetUser(r.Context()).Id,
|
||||
}
|
||||
if err := events.Publish(r.Context(), g.eventsPublisher, ev); err != nil {
|
||||
g.logger.Error().Err(err).Msg("Failed to publish LabelAdded event")
|
||||
}
|
||||
}
|
||||
|
||||
driveItem, err := cs3ResourceToDriveItem(g.logger, g.publicBaseURL, statRes.GetInfo())
|
||||
if err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusCreated)
|
||||
render.JSON(w, r, &driveItem)
|
||||
}
|
||||
|
||||
// UnfollowDriveItem unmarks a drive item as favorite.
|
||||
func (g Graph) UnfollowDriveItem(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
itemID, err := parseIDParam(r, "itemID")
|
||||
if err != nil {
|
||||
g.logger.Debug().Err(err).Msg("could not parse itemID")
|
||||
return
|
||||
}
|
||||
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusServiceUnavailable, "could not select next gateway client")
|
||||
return
|
||||
}
|
||||
|
||||
ref := &provider.Reference{
|
||||
ResourceId: &itemID,
|
||||
}
|
||||
|
||||
u, ok := revactx.ContextGetUser(ctx)
|
||||
if !ok {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusUnauthorized, "User not found in context")
|
||||
return
|
||||
}
|
||||
|
||||
req := &provider.RemoveLabelRequest{
|
||||
Ref: ref,
|
||||
UserId: u.Id,
|
||||
Label: _favoriteLabel,
|
||||
}
|
||||
|
||||
res, err := gatewayClient.RemoveLabel(ctx, req)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not remove label")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not remove label")
|
||||
return
|
||||
}
|
||||
|
||||
switch res.Status.Code {
|
||||
case rpc.Code_CODE_OK:
|
||||
// continue
|
||||
case rpc.Code_CODE_NOT_FOUND:
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "favorite not found")
|
||||
return
|
||||
default:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not remove favorite")
|
||||
return
|
||||
}
|
||||
|
||||
if g.eventsPublisher != nil {
|
||||
ev := events.LabelRemoved{
|
||||
Ref: &provider.Reference{
|
||||
ResourceId: &itemID,
|
||||
Path: ".",
|
||||
},
|
||||
Label: _favoriteLabel,
|
||||
UserID: u.Id,
|
||||
Executant: revaCtx.ContextMustGetUser(r.Context()).Id,
|
||||
}
|
||||
if err := events.Publish(r.Context(), g.eventsPublisher, ev); err != nil {
|
||||
g.logger.Error().Err(err).Msg("Failed to publish LabelRemoved event")
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jellydator/ttlcache/v3"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"go-micro.dev/v4/client"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
|
||||
"github.com/qsfera/server/pkg/keycloak"
|
||||
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
"github.com/qsfera/server/services/graph/pkg/identity"
|
||||
)
|
||||
|
||||
// Permissions is the interface used to access the permissions service
|
||||
type Permissions interface {
|
||||
ListPermissions(ctx context.Context, req *settingssvc.ListPermissionsRequest, opts ...client.CallOption) (*settingssvc.ListPermissionsResponse, error)
|
||||
GetPermissionByID(ctx context.Context, request *settingssvc.GetPermissionByIDRequest, opts ...client.CallOption) (*settingssvc.GetPermissionByIDResponse, error)
|
||||
ListPermissionsByResource(ctx context.Context, in *settingssvc.ListPermissionsByResourceRequest, opts ...client.CallOption) (*settingssvc.ListPermissionsByResourceResponse, error)
|
||||
}
|
||||
|
||||
// HTTPClient is the subset of the http.Client that is being used to interact with the download gateway
|
||||
type HTTPClient interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// GetGatewayServiceClientFunc is a callback used to pass in a mock during testing
|
||||
type GetGatewayServiceClientFunc func() (gateway.GatewayAPIClient, error)
|
||||
|
||||
// RoleService is the interface used to access the role service
|
||||
type RoleService interface {
|
||||
ListRoles(ctx context.Context, in *settingssvc.ListBundlesRequest, opts ...client.CallOption) (*settingssvc.ListBundlesResponse, error)
|
||||
ListRoleAssignments(ctx context.Context, in *settingssvc.ListRoleAssignmentsRequest, opts ...client.CallOption) (*settingssvc.ListRoleAssignmentsResponse, error)
|
||||
ListRoleAssignmentsFiltered(ctx context.Context, in *settingssvc.ListRoleAssignmentsFilteredRequest, opts ...client.CallOption) (*settingssvc.ListRoleAssignmentsResponse, error)
|
||||
AssignRoleToUser(ctx context.Context, in *settingssvc.AssignRoleToUserRequest, opts ...client.CallOption) (*settingssvc.AssignRoleToUserResponse, error)
|
||||
RemoveRoleFromUser(ctx context.Context, in *settingssvc.RemoveRoleFromUserRequest, opts ...client.CallOption) (*emptypb.Empty, error)
|
||||
}
|
||||
|
||||
// Graph defines implements the business logic for Service.
|
||||
type Graph struct {
|
||||
BaseGraphService
|
||||
mux *chi.Mux
|
||||
identityBackend identity.Backend
|
||||
identityEducationBackend identity.EducationBackend
|
||||
roleService RoleService
|
||||
permissionsService Permissions
|
||||
valueService settingssvc.ValueService
|
||||
specialDriveItemsCache *ttlcache.Cache[string, any]
|
||||
eventsPublisher events.Publisher
|
||||
eventsConsumer events.Consumer
|
||||
searchService searchsvc.SearchProviderService
|
||||
keycloakClient keycloak.Client
|
||||
historyClient ehsvc.EventHistoryService
|
||||
traceProvider trace.TracerProvider
|
||||
natskv jetstream.KeyValue
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (g Graph) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// There was a number of issues with the chi router and parameters with
|
||||
// slashes/percentage/other characters that didn't get properly escaped.
|
||||
// This is a workaround to fix this. Also, we're not the only ones who have
|
||||
// tried to fix this, as seen in this issue:
|
||||
// https://github.com/go-chi/chi/issues/641#issuecomment-883156692
|
||||
r.URL.RawPath = r.URL.EscapedPath()
|
||||
|
||||
g.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (g Graph) publishEvent(ctx context.Context, ev any) {
|
||||
if g.eventsPublisher != nil {
|
||||
if err := events.Publish(ctx, g.eventsPublisher, ev); err != nil {
|
||||
g.logger.Error().
|
||||
Err(err).
|
||||
Msg("could not publish user created event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g Graph) getWebDavBaseURL() (*url.URL, error) {
|
||||
webDavBaseURL, err := url.Parse(g.config.Spaces.WebDavBase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webDavBaseURL.Path = path.Join(webDavBaseURL.Path, g.config.Spaces.WebDavPath)
|
||||
return webDavBaseURL, nil
|
||||
}
|
||||
|
||||
// ListResponse is used for proper marshalling of Graph list responses
|
||||
type ListResponse struct {
|
||||
Value any `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
// ReadmeSpecialFolderName for the drive specialFolder property
|
||||
ReadmeSpecialFolderName = "readme"
|
||||
// SpaceImageSpecialFolderName for the drive specialFolder property
|
||||
SpaceImageSpecialFolderName = "image"
|
||||
)
|
||||
|
||||
type APIVersion int
|
||||
|
||||
const (
|
||||
// APIVersion_1 represents the first version of the API.
|
||||
APIVersion_1 APIVersion = iota + 1
|
||||
|
||||
// APIVersion_1_Beta_1 refers to the beta version of the API.
|
||||
// It is typically used for testing purposes and may have more
|
||||
// inconsistencies and bugs than the stable version as it is
|
||||
// still in the testing phase, use it with caution.
|
||||
APIVersion_1_Beta_1
|
||||
)
|
||||
|
||||
// TODO might be different for /education/users vs /users
|
||||
func (g Graph) parseMemberRef(ref string) (string, string, error) {
|
||||
memberURL, err := url.ParseRequestURI(ref)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
segments := strings.Split(memberURL.Path, "/")
|
||||
if len(segments) < 2 {
|
||||
return "", "", errors.New("invalid member reference")
|
||||
}
|
||||
id := segments[len(segments)-1]
|
||||
memberType := segments[len(segments)-2]
|
||||
return memberType, id, nil
|
||||
}
|
||||
|
||||
func parseIDParam(r *http.Request, param string) (storageprovider.ResourceId, error) {
|
||||
driveID, err := url.PathUnescape(chi.URLParam(r, param))
|
||||
if err != nil {
|
||||
return storageprovider.ResourceId{}, errorcode.New(errorcode.InvalidRequest, err.Error())
|
||||
}
|
||||
|
||||
id, err := storagespace.ParseID(driveID)
|
||||
if err != nil {
|
||||
return storageprovider.ResourceId{}, errorcode.New(errorcode.InvalidRequest, err.Error())
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
mRegistry "go-micro.dev/v4/registry"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func init() {
|
||||
r := registry.GetRegistry(registry.Inmemory())
|
||||
service := registry.BuildGRPCService("qsfera.api.gateway", "", "", "")
|
||||
service.Nodes = []*mRegistry.Node{{
|
||||
Address: "any",
|
||||
}}
|
||||
|
||||
_ = r.Register(service)
|
||||
}
|
||||
|
||||
func TestGraph(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Graph Suite")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,489 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
)
|
||||
|
||||
const memberTypeUsers = "users"
|
||||
|
||||
// GetGroups implements the Service interface.
|
||||
func (g Graph) GetGroups(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Interface("query", r.URL.Query()).Msg("calling get groups")
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get groups: query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
ctxHasFullPerms := g.contextUserHasFullAccountPerms(r.Context())
|
||||
searchHasAcceptableLength := false
|
||||
if odataReq.Query != nil && odataReq.Query.Search != nil {
|
||||
minSearchLength := g.config.API.IdentitySearchMinLength
|
||||
if strings.HasPrefix(odataReq.Query.Search.RawValue, "\"") {
|
||||
// if search starts with double quotes then it must finish with double quotes
|
||||
// add +2 to the minimum search length in this case
|
||||
minSearchLength += 2
|
||||
}
|
||||
searchHasAcceptableLength = len(odataReq.Query.Search.RawValue) >= minSearchLength
|
||||
}
|
||||
if !ctxHasFullPerms && !searchHasAcceptableLength {
|
||||
// for regular user the search term must have a minimum length
|
||||
logger.Debug().Interface("query", r.URL.Query()).Msgf("search with less than %d chars for a regular user", g.config.API.IdentitySearchMinLength)
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "search term too short")
|
||||
return
|
||||
}
|
||||
|
||||
if !ctxHasFullPerms && (odataReq.Query.Filter != nil || odataReq.Query.Apply != nil || odataReq.Query.Expand != nil || odataReq.Query.Compute != nil) {
|
||||
// regular users can't use filter, apply, expand and compute
|
||||
logger.Debug().Interface("query", r.URL.Query()).Msg("forbidden query elements for a regular user")
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "query has forbidden elements for regular users")
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := g.identityBackend.GetGroups(r.Context(), odataReq)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not get groups: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
// If the user isn't admin, we'll show just the minimum group attibutes
|
||||
if !ctxHasFullPerms {
|
||||
finalGroups := make([]*libregraph.Group, len(groups))
|
||||
for i, grp := range groups {
|
||||
finalGroups[i] = &libregraph.Group{
|
||||
Id: grp.Id,
|
||||
DisplayName: grp.DisplayName,
|
||||
GroupTypes: grp.GroupTypes,
|
||||
}
|
||||
}
|
||||
groups = finalGroups
|
||||
}
|
||||
|
||||
groups, err = sortGroups(odataReq, groups)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get groups: could not sort groups according to query")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &ListResponse{Value: groups})
|
||||
}
|
||||
|
||||
// PostGroup implements the Service interface.
|
||||
func (g Graph) PostGroup(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Msg("calling post group")
|
||||
grp := libregraph.NewGroup()
|
||||
err := StrictJSONUnmarshal(r.Body, grp)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create group: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if displayName, ok := grp.GetDisplayNameOk(); ok {
|
||||
if !isValidGroupName(*displayName) {
|
||||
logger.Info().Str("group", *displayName).Msg("could not create group: invalid displayName")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Invalid displayName")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
logger.Debug().Err(err).Interface("group", grp).Msg("could not create group: missing required attribute")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute")
|
||||
return
|
||||
}
|
||||
|
||||
// Disallow user-supplied IDs. It's supposed to be readonly. We're either
|
||||
// generating them in the backend ourselves or rely on the Backend's
|
||||
// storage (e.g. LDAP) to provide a unique ID.
|
||||
if _, ok := grp.GetIdOk(); ok {
|
||||
logger.Debug().Msg("could not create group: id is a read-only attribute")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "group id is a read-only attribute")
|
||||
return
|
||||
}
|
||||
|
||||
if grp, err = g.identityBackend.CreateGroup(r.Context(), *grp); err != nil {
|
||||
logger.Debug().Err(err).Interface("group", grp).Msg("could not create group: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if grp != nil && grp.Id != nil {
|
||||
e := events.GroupCreated{
|
||||
GroupID: grp.GetId(),
|
||||
}
|
||||
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(r.Context(), e)
|
||||
}
|
||||
render.Status(r, http.StatusCreated)
|
||||
render.JSON(w, r, grp)
|
||||
}
|
||||
|
||||
// PatchGroup implements the Service interface.
|
||||
func (g Graph) PatchGroup(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Msg("calling patch group")
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
groupID, err := url.PathUnescape(groupID)
|
||||
if err != nil {
|
||||
logger.Debug().Str("id", groupID).Msg("could not change group: unescaping group id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if groupID == "" {
|
||||
logger.Debug().Msg("could not change group: missing group id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
changes := libregraph.NewGroup()
|
||||
err = StrictJSONUnmarshal(r.Body, changes)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not change group: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if reflect.ValueOf(*changes).IsZero() {
|
||||
logger.Debug().Interface("body", r.Body).Msg("ignoring empyt request body")
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if changes.HasDisplayName() {
|
||||
displayName := changes.GetDisplayName()
|
||||
if !isValidGroupName(displayName) {
|
||||
logger.Info().Str("group", displayName).Msg("could not update group: invalid displayName")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Invalid displayName")
|
||||
return
|
||||
}
|
||||
if err = g.identityBackend.UpdateGroupName(r.Context(), groupID, displayName); err != nil {
|
||||
logger.Debug().Err(err).Msg("could not update group displayName")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if memberRefs, ok := changes.GetMembersodataBindOk(); ok {
|
||||
// The spec defines a limit of 20 members maxium per Request
|
||||
if len(memberRefs) > g.config.API.GroupMembersPatchLimit {
|
||||
logger.Debug().
|
||||
Int("number", len(memberRefs)).
|
||||
Int("limit", g.config.API.GroupMembersPatchLimit).
|
||||
Msg("could not add group members, exceeded members limit")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
|
||||
fmt.Sprintf("Request is limited to %d members", g.config.API.GroupMembersPatchLimit))
|
||||
return
|
||||
}
|
||||
memberIDs := make([]string, 0, len(memberRefs))
|
||||
for _, memberRef := range memberRefs {
|
||||
memberType, id, err := g.parseMemberRef(memberRef)
|
||||
if err != nil {
|
||||
logger.Debug().
|
||||
Str("memberref", memberRef).
|
||||
Msg("could not change group: Error parsing member@odata.bind values")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing member@odata.bind values")
|
||||
return
|
||||
}
|
||||
logger.Debug().Str("membertype", memberType).Str("memberid", id).Msg("add group member")
|
||||
// The MS Graph spec allows "directoryObject", "user", "group" and "organizational Contact"
|
||||
// we restrict this to users for now. Might add Groups as members later
|
||||
if memberType != memberTypeUsers {
|
||||
logger.Debug().
|
||||
Str("type", memberType).
|
||||
Msg("could not change group: could not add member, only user type is allowed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only user are allowed as group members")
|
||||
return
|
||||
}
|
||||
memberIDs = append(memberIDs, id)
|
||||
}
|
||||
if err = g.identityBackend.AddMembersToGroup(r.Context(), groupID, memberIDs); err != nil {
|
||||
logger.Debug().Err(err).Msg("could not change group: backend could not add members")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusNoContent) // TODO StatusNoContent when prefer=minimal is used, otherwise OK and the resource in the body
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// GetGroup implements the Service interface.
|
||||
func (g Graph) GetGroup(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Msg("calling get group")
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
groupID, err := url.PathUnescape(groupID)
|
||||
if err != nil {
|
||||
logger.Debug().Str("id", groupID).Msg("could not get group: unescaping group id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
}
|
||||
|
||||
if groupID == "" {
|
||||
logger.Debug().Msg("could not get group: missing group id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
Str("id", groupID).
|
||||
Interface("query", r.URL.Query()).
|
||||
Msg("calling get group on backend")
|
||||
group, err := g.identityBackend.GetGroup(r.Context(), groupID, r.URL.Query())
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get group: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, group)
|
||||
}
|
||||
|
||||
// DeleteGroup implements the Service interface.
|
||||
func (g Graph) DeleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Msg("calling delete group")
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
groupID, err := url.PathUnescape(groupID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", groupID).Msg("could not delete group: unescaping group id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if groupID == "" {
|
||||
logger.Debug().Msg("could not delete group: missing group id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("id", groupID).Msg("calling delete group on backend")
|
||||
err = g.identityBackend.DeleteGroup(r.Context(), groupID)
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not delete group: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
e := events.GroupDeleted{
|
||||
GroupID: groupID,
|
||||
}
|
||||
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(r.Context(), e)
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// GetGroupMembers implements the Service interface.
|
||||
func (g Graph) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Msg("calling get group members")
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
groupID, err := url.PathUnescape(groupID)
|
||||
if err != nil {
|
||||
logger.Debug().Str("id", groupID).Msg("could not get group members: unescaping group id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if groupID == "" {
|
||||
logger.Debug().Msg("could not get group members: missing group id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get users: query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("id", groupID).Msg("calling get group members on backend")
|
||||
members, err := g.identityBackend.GetGroupMembers(r.Context(), groupID, odataReq)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not get group members: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, members)
|
||||
}
|
||||
|
||||
// PostGroupMember implements the Service interface.
|
||||
func (g Graph) PostGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Msg("Calling post group member")
|
||||
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
groupID, err := url.PathUnescape(groupID)
|
||||
if err != nil {
|
||||
logger.Debug().
|
||||
Err(err).
|
||||
Str("id", groupID).
|
||||
Msg("could not add member to group: unescaping group id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if groupID == "" {
|
||||
logger.Debug().Msg("could not add group member: missing group id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
memberRef := libregraph.NewMemberReference()
|
||||
err = StrictJSONUnmarshal(r.Body, memberRef)
|
||||
if err != nil {
|
||||
logger.Debug().
|
||||
Err(err).
|
||||
Interface("body", r.Body).
|
||||
Msg("could not add group member: invalid request body")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
memberRefURL, ok := memberRef.GetOdataIdOk()
|
||||
if !ok {
|
||||
logger.Debug().Msg("could not add group member: @odata.id reference is missing")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
|
||||
return
|
||||
}
|
||||
memberType, id, err := g.parseMemberRef(*memberRefURL)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not add group member: error parsing @odata.id url")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
|
||||
return
|
||||
}
|
||||
// The MS Graph spec allows "directoryObject", "user", "group" and "organizational Contact"
|
||||
// we restrict this to users for now. Might add Groups as members later
|
||||
if memberType != memberTypeUsers {
|
||||
logger.Debug().Str("type", memberType).Msg("could not add group member: Only users are allowed as group members")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as group members")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add member on backend")
|
||||
err = g.identityBackend.AddMembersToGroup(r.Context(), groupID, []string{id})
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not add group member: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
e := events.GroupMemberAdded{
|
||||
GroupID: groupID,
|
||||
UserID: id,
|
||||
}
|
||||
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(r.Context(), e)
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// DeleteGroupMember implements the Service interface.
|
||||
func (g Graph) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Info().Msg("calling delete group member")
|
||||
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
groupID, err := url.PathUnescape(groupID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", groupID).Msg("could not delete group member: unescaping group id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if groupID == "" {
|
||||
logger.Debug().Msg("could not delete group member: missing group id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
|
||||
memberID := chi.URLParam(r, "memberID")
|
||||
memberID, err = url.PathUnescape(memberID)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("id", memberID).Msg("could not delete group member: unescaping member id failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping member id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if memberID == "" {
|
||||
logger.Debug().Msg("could not delete group member: missing member id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id")
|
||||
return
|
||||
}
|
||||
logger.Debug().Str("groupID", groupID).Str("memberID", memberID).Msg("calling delete member on backend")
|
||||
err = g.identityBackend.RemoveMemberFromGroup(r.Context(), groupID, memberID)
|
||||
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not delete group member: backend error")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
e := events.GroupMemberRemoved{
|
||||
GroupID: groupID,
|
||||
UserID: memberID,
|
||||
}
|
||||
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
|
||||
e.Executant = currentUser.GetId()
|
||||
}
|
||||
g.publishEvent(r.Context(), e)
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
func sortGroups(req *godata.GoDataRequest, groups []*libregraph.Group) ([]*libregraph.Group, error) {
|
||||
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
|
||||
return groups, nil
|
||||
}
|
||||
var less func(i, j int) bool
|
||||
|
||||
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
|
||||
case displayNameAttr:
|
||||
less = func(i, j int) bool {
|
||||
return strings.ToLower(groups[i].GetDisplayName()) < strings.ToLower(groups[j].GetDisplayName())
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
|
||||
}
|
||||
|
||||
if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending {
|
||||
sort.Slice(groups, reverse(less))
|
||||
} else {
|
||||
sort.Slice(groups, less)
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func isValidGroupName(e string) bool {
|
||||
str := strings.TrimSpace(e)
|
||||
return len(str) > 0 && len(str) <= 256
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
|
||||
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/graph/mocks"
|
||||
"github.com/qsfera/server/services/graph/pkg/config"
|
||||
"github.com/qsfera/server/services/graph/pkg/config/defaults"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
|
||||
service "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
)
|
||||
|
||||
type groupList struct {
|
||||
Value []*libregraph.Group
|
||||
}
|
||||
|
||||
var _ = Describe("Groups", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
eventsPublisher mocks.Publisher
|
||||
identityBackend *identitymocks.Backend
|
||||
permissionService *mocks.Permissions
|
||||
|
||||
rr *httptest.ResponseRecorder
|
||||
|
||||
newGroup *libregraph.Group
|
||||
currentUser = &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "user",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
permissionService = &mocks.Permissions{}
|
||||
|
||||
identityBackend = &identitymocks.Backend{}
|
||||
newGroup = libregraph.NewGroup()
|
||||
newGroup.SetMembersodataBind([]string{"/users/user1"})
|
||||
newGroup.SetId("group1")
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
ctx = context.Background()
|
||||
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
cfg.Commons = &shared.Commons{}
|
||||
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
|
||||
|
||||
var err error
|
||||
svc, err = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
service.WithIdentityBackend(identityBackend),
|
||||
service.PermissionService(permissionService),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("GetGroups", func() {
|
||||
It("handles invalid ODATA parameters", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups?§foo=bar", nil)
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles invalid sorting queries", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
|
||||
Permission: &settingsmsg.Permission{
|
||||
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
|
||||
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
|
||||
},
|
||||
}, nil)
|
||||
identityBackend.On("GetGroups", ctx, mock.Anything).Return([]*libregraph.Group{newGroup}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups?$orderby=invalid", nil)
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
odataerr := libregraph.OdataError{}
|
||||
err = json.Unmarshal(data, &odataerr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(odataerr.Error.Code).To(Equal("invalidRequest"))
|
||||
})
|
||||
|
||||
It("handles unknown backend errors", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
|
||||
Permission: &settingsmsg.Permission{
|
||||
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
|
||||
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
|
||||
},
|
||||
}, nil)
|
||||
identityBackend.On("GetGroups", ctx, mock.Anything).Return(nil, errors.New("failed"))
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
|
||||
svc.GetGroups(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
odataerr := libregraph.OdataError{}
|
||||
err = json.Unmarshal(data, &odataerr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(odataerr.Error.Code).To(Equal("generalException"))
|
||||
})
|
||||
|
||||
It("handles backend errors", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
|
||||
Permission: &settingsmsg.Permission{
|
||||
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
|
||||
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
|
||||
},
|
||||
}, nil)
|
||||
identityBackend.On("GetGroups", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
odataerr := libregraph.OdataError{}
|
||||
err = json.Unmarshal(data, &odataerr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(odataerr.Error.Code).To(Equal("accessDenied"))
|
||||
})
|
||||
|
||||
It("renders an empty list of groups", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
|
||||
Permission: &settingsmsg.Permission{
|
||||
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
|
||||
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
|
||||
},
|
||||
}, nil)
|
||||
identityBackend.On("GetGroups", ctx, mock.Anything).Return([]*libregraph.Group{}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := service.ListResponse{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Value).To(Equal([]any{}))
|
||||
})
|
||||
|
||||
It("renders a list of groups", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
|
||||
Permission: &settingsmsg.Permission{
|
||||
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
|
||||
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
|
||||
},
|
||||
}, nil)
|
||||
identityBackend.On("GetGroups", ctx, mock.Anything).Return([]*libregraph.Group{newGroup}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := groupList{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
Expect(res.Value[0].GetId()).To(Equal("group1"))
|
||||
})
|
||||
It("denies listing for unprivileged users", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users", nil)
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
It("denies using to short search terms for unprivileged users", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$search=a", nil)
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
It("denies using to short quoted search terms for unprivileged users", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups?$search=%22ab%22", nil)
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
It("only returns a restricted set of attributes for unprivileged users", func() {
|
||||
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
|
||||
group := &libregraph.Group{}
|
||||
group.SetId("group1")
|
||||
group.SetDisplayName("Group Name")
|
||||
group.SetMembers(
|
||||
[]libregraph.User{
|
||||
{
|
||||
Id: libregraph.PtrString("userid"),
|
||||
},
|
||||
},
|
||||
)
|
||||
groups := []*libregraph.Group{group}
|
||||
|
||||
identityBackend.On("GetGroups", mock.Anything, mock.Anything, mock.Anything).Return(groups, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups?$search=abc", nil)
|
||||
svc.GetGroups(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := groupList{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
groupMap, err := res.Value[0].ToMap()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for k := range groupMap {
|
||||
Expect(k).Should(BeElementOf([]string{"displayName", "id", "groupTypes"}))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetGroup", func() {
|
||||
It("handles missing or empty group id", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
|
||||
svc.GetGroup(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
|
||||
r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", "")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
|
||||
svc.GetGroup(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
Context("with an existing group", func() {
|
||||
BeforeEach(func() {
|
||||
identityBackend.On("GetGroup", mock.Anything, mock.Anything, mock.Anything).Return(newGroup, nil)
|
||||
})
|
||||
|
||||
It("gets the group", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups/"+*newGroup.Id, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
|
||||
|
||||
svc.GetGroup(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PostGroup", func() {
|
||||
It("handles invalid body", func() {
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/", bytes.NewBufferString("{invalid"))
|
||||
|
||||
svc.PostGroup(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles missing display name", func() {
|
||||
newGroup = libregraph.NewGroup()
|
||||
newGroup.SetId("disallowed")
|
||||
newGroup.SetMembersodataBind([]string{"/non-users/user"})
|
||||
newGroupJson, err := json.Marshal(newGroup)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/", bytes.NewBuffer(newGroupJson))
|
||||
|
||||
svc.PostGroup(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("disallows group create ids", func() {
|
||||
newGroup = libregraph.NewGroup()
|
||||
newGroup.SetId("disallowed")
|
||||
newGroup.SetDisplayName("New Group")
|
||||
newGroup.SetMembersodataBind([]string{"/non-users/user"})
|
||||
newGroupJson, err := json.Marshal(newGroup)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/", bytes.NewBuffer(newGroupJson))
|
||||
|
||||
svc.PostGroup(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("creates the group", func() {
|
||||
newGroup = libregraph.NewGroup()
|
||||
newGroup.SetDisplayName("New Group")
|
||||
newGroupJson, err := json.Marshal(newGroup)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
identityBackend.On("CreateGroup", mock.Anything, mock.Anything).Return(newGroup, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/", bytes.NewBuffer(newGroupJson))
|
||||
|
||||
svc.PostGroup(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusCreated))
|
||||
})
|
||||
})
|
||||
Describe("PatchGroup", func() {
|
||||
It("handles invalid body", func() {
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups/", bytes.NewBufferString("{invalid"))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchGroup(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("handles missing or empty group id", func() {
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", nil)
|
||||
svc.PatchGroup(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
|
||||
r = httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", "")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchGroup(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
Context("with an existing group", func() {
|
||||
BeforeEach(func() {
|
||||
identityBackend.On("GetGroup", mock.Anything, mock.Anything, mock.Anything).Return(newGroup, nil)
|
||||
})
|
||||
|
||||
It("fails when the number of users is exceeded - spec says 20 max", func() {
|
||||
updatedGroup := libregraph.NewGroup()
|
||||
updatedGroup.SetMembersodataBind([]string{
|
||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
|
||||
"19", "20", "21",
|
||||
})
|
||||
updatedGroupJson, err := json.Marshal(updatedGroup)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", bytes.NewBuffer(updatedGroupJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchGroup(rr, r)
|
||||
|
||||
resp, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(string(resp)).To(ContainSubstring("Request is limited to 20"))
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("succeeds when the number of users is over 20 but the limit is raised to 21", func() {
|
||||
updatedGroup := libregraph.NewGroup()
|
||||
updatedGroup.SetMembersodataBind([]string{
|
||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
|
||||
"19", "20", "21",
|
||||
})
|
||||
updatedGroupJson, err := json.Marshal(updatedGroup)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
cfg.API.GroupMembersPatchLimit = 21
|
||||
svc, err = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
service.WithIdentityBackend(identityBackend),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", bytes.NewBuffer(updatedGroupJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchGroup(rr, r)
|
||||
|
||||
resp, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(string(resp)).To(ContainSubstring("Error parsing member@odata.bind values"))
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails on invalid user refs", func() {
|
||||
updatedGroup := libregraph.NewGroup()
|
||||
updatedGroup.SetMembersodataBind([]string{"invalid"})
|
||||
updatedGroupJson, err := json.Marshal(updatedGroup)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", bytes.NewBuffer(updatedGroupJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchGroup(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails when the adding non-users users", func() {
|
||||
updatedGroup := libregraph.NewGroup()
|
||||
updatedGroup.SetMembersodataBind([]string{"/non-users/user1"})
|
||||
updatedGroupJson, err := json.Marshal(updatedGroup)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", bytes.NewBuffer(updatedGroupJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchGroup(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("adds members to the group", func() {
|
||||
identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
updatedGroup := libregraph.NewGroup()
|
||||
updatedGroup.SetMembersodataBind([]string{"/users/user1"})
|
||||
updatedGroupJson, err := json.Marshal(updatedGroup)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", bytes.NewBuffer(updatedGroupJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchGroup(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
|
||||
})
|
||||
|
||||
It("updates the group name", func() {
|
||||
identityBackend.On("UpdateGroupName", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
updatedGroup := libregraph.NewGroup()
|
||||
updatedGroup.SetDisplayName("group1 updated")
|
||||
updatedGroupJson, err := json.Marshal(updatedGroup)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", bytes.NewBuffer(updatedGroupJson))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PatchGroup(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
identityBackend.AssertNumberOfCalls(GinkgoT(), "UpdateGroupName", 1)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteGroup", func() {
|
||||
Context("with an existing group", func() {
|
||||
BeforeEach(func() {
|
||||
identityBackend.On("GetGroup", mock.Anything, mock.Anything, mock.Anything).Return(newGroup, nil)
|
||||
})
|
||||
})
|
||||
|
||||
It("deletes the group", func() {
|
||||
identityBackend.On("DeleteGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/groups", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteGroup(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
identityBackend.AssertNumberOfCalls(GinkgoT(), "DeleteGroup", 1)
|
||||
eventsPublisher.AssertNumberOfCalls(GinkgoT(), "Publish", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetGroupMembers", func() {
|
||||
It("gets the list of members", func() {
|
||||
user := libregraph.NewUser("display name", "username")
|
||||
user.SetId("userid")
|
||||
identityBackend.On("GetGroupMembers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.User{user}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/groups/{groupID}/members", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.GetGroupMembers(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var members []*libregraph.User
|
||||
err = json.Unmarshal(data, &members)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(members)).To(Equal(1))
|
||||
Expect(members[0].GetId()).To(Equal("userid"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PostGroupMembers", func() {
|
||||
It("fails on invalid body", func() {
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/{groupID}/members", bytes.NewBufferString("{invalid"))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostGroupMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails on missing member refs", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/{groupID}/members", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostGroupMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("fails on invalid member refs", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
member.SetOdataId("/invalidtype/user")
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/{groupID}/members", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostGroupMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("adds a new member", func() {
|
||||
member := libregraph.NewMemberReference()
|
||||
member.SetOdataId("/users/user")
|
||||
data, err := json.Marshal(member)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/groups/{groupID}/members", bytes.NewBuffer(data))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.PostGroupMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteGroupMembers", func() {
|
||||
It("handles missing or empty member id", func() {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/groups/{groupID}/members/{memberID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteGroupMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
It("handles missing or empty member id", func() {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/groups/{groupID}/members/{memberID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("memberID", "/users/user")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteGroupMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("deletes members", func() {
|
||||
identityBackend.On("RemoveMemberFromGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/groups/{groupID}/members/{memberID}/$ref", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("groupID", *newGroup.Id)
|
||||
rctx.URLParams.Add("memberID", "/users/user1")
|
||||
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
|
||||
svc.DeleteGroupMember(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusNoContent))
|
||||
|
||||
identityBackend.AssertNumberOfCalls(GinkgoT(), "RemoveMemberFromGroup", 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/qsfera/server/pkg/keycloak"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/roles"
|
||||
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/graph/pkg/config"
|
||||
"github.com/qsfera/server/services/graph/pkg/identity"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Context context.Context
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
Middleware []func(http.Handler) http.Handler
|
||||
RequireAdminMiddleware func(http.Handler) http.Handler
|
||||
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
IdentityBackend identity.Backend
|
||||
IdentityEducationBackend identity.EducationBackend
|
||||
RoleService RoleService
|
||||
UserProfilePhotoService UsersUserProfilePhotoProvider
|
||||
PermissionService Permissions
|
||||
ValueService settingssvc.ValueService
|
||||
RoleManager *roles.Manager
|
||||
EventsPublisher events.Publisher
|
||||
EventsConsumer events.Consumer
|
||||
SearchService searchsvc.SearchProviderService
|
||||
KeycloakClient keycloak.Client
|
||||
EventHistoryClient ehsvc.EventHistoryService
|
||||
TraceProvider trace.TracerProvider
|
||||
NatsKeyValue jetstream.KeyValue
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(ctx context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware provides a function to set the middleware option.
|
||||
func Middleware(val ...func(http.Handler) http.Handler) Option {
|
||||
return func(o *Options) {
|
||||
o.Middleware = val
|
||||
}
|
||||
}
|
||||
|
||||
// WithRequireAdminMiddleware provides a function to set the RequireAdminMiddleware option.
|
||||
func WithRequireAdminMiddleware(val func(http.Handler) http.Handler) Option {
|
||||
return func(o *Options) {
|
||||
o.RequireAdminMiddleware = val
|
||||
}
|
||||
}
|
||||
|
||||
// WithGatewaySelector provides a function to set the gateway client option.
|
||||
func WithGatewaySelector(val pool.Selectable[gateway.GatewayAPIClient]) Option {
|
||||
return func(o *Options) {
|
||||
o.GatewaySelector = val
|
||||
}
|
||||
}
|
||||
|
||||
// WithIdentityBackend provides a function to set the IdentityBackend option.
|
||||
func WithIdentityBackend(val identity.Backend) Option {
|
||||
return func(o *Options) {
|
||||
o.IdentityBackend = val
|
||||
}
|
||||
}
|
||||
|
||||
// WithIdentityEducationBackend provides a function to set the IdentityEducationBackend option.
|
||||
func WithIdentityEducationBackend(val identity.EducationBackend) Option {
|
||||
return func(o *Options) {
|
||||
o.IdentityEducationBackend = val
|
||||
}
|
||||
}
|
||||
|
||||
// WithNatsKeyValue provides a function to set the NatsKeyValue option.
|
||||
func WithNatsKeyValue(val jetstream.KeyValue) Option {
|
||||
return func(o *Options) {
|
||||
o.NatsKeyValue = val
|
||||
}
|
||||
}
|
||||
|
||||
// WithRoleService provides a function to set the RoleService option.
|
||||
func WithRoleService(val RoleService) Option {
|
||||
return func(o *Options) {
|
||||
o.RoleService = val
|
||||
}
|
||||
}
|
||||
|
||||
// WithValueService provides a function to set the ValueService option.
|
||||
func WithValueService(val settingssvc.ValueService) Option {
|
||||
return func(o *Options) {
|
||||
o.ValueService = val
|
||||
}
|
||||
}
|
||||
|
||||
// WithSearchService provides a function to set the SearchService option.
|
||||
func WithSearchService(val searchsvc.SearchProviderService) Option {
|
||||
return func(o *Options) {
|
||||
o.SearchService = val
|
||||
}
|
||||
}
|
||||
|
||||
// PermissionService provides a function to set the PermissionService option.
|
||||
func PermissionService(val settingssvc.PermissionService) Option {
|
||||
return func(o *Options) {
|
||||
o.PermissionService = val
|
||||
}
|
||||
}
|
||||
|
||||
// RoleManager provides a function to set the RoleManager option.
|
||||
func RoleManager(val *roles.Manager) Option {
|
||||
return func(o *Options) {
|
||||
o.RoleManager = val
|
||||
}
|
||||
}
|
||||
|
||||
// EventsPublisher provides a function to set the EventsPublisher option.
|
||||
func EventsPublisher(val events.Publisher) Option {
|
||||
return func(o *Options) {
|
||||
o.EventsPublisher = val
|
||||
}
|
||||
}
|
||||
|
||||
// EventsConsumer provides a function to set the EventsConsumer option.
|
||||
func EventsConsumer(val events.Consumer) Option {
|
||||
return func(o *Options) {
|
||||
o.EventsConsumer = val
|
||||
}
|
||||
}
|
||||
|
||||
// KeycloakClient provides a function to set the KeycloakCient option.
|
||||
func KeycloakClient(val keycloak.Client) Option {
|
||||
return func(o *Options) {
|
||||
o.KeycloakClient = val
|
||||
}
|
||||
}
|
||||
|
||||
// EventHistoryClient provides a function to set the EventHistoryClient option.
|
||||
func EventHistoryClient(val ehsvc.EventHistoryService) Option {
|
||||
return func(o *Options) {
|
||||
o.EventHistoryClient = val
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider provides a function to set the TraceProvider option.
|
||||
func TraceProvider(val trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TraceProvider = val
|
||||
}
|
||||
}
|
||||
|
||||
// UserProfilePhotoService provides a function to set the UserProfilePhotoService option.
|
||||
func UserProfilePhotoService(p UsersUserProfilePhotoProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.UserProfilePhotoService = p
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
// lessSpacesByLastModifiedDateTime reports whether the element i
|
||||
// must sort before the element j.
|
||||
func lessSpacesByLastModifiedDateTime(i, j *libregraph.Drive) bool {
|
||||
// compare the items when both dates are set
|
||||
if i.LastModifiedDateTime != nil && j.LastModifiedDateTime != nil {
|
||||
return i.LastModifiedDateTime.Before(*j.LastModifiedDateTime)
|
||||
}
|
||||
// an item without a timestamp is considered "less than" an item with a timestamp
|
||||
if i.LastModifiedDateTime == nil && j.LastModifiedDateTime != nil {
|
||||
return true
|
||||
}
|
||||
// an item without a timestamp is considered "less than" an item with a timestamp
|
||||
if i.LastModifiedDateTime != nil && j.LastModifiedDateTime == nil {
|
||||
return false
|
||||
}
|
||||
// fallback to name if no dateTime is set on both items
|
||||
return strings.ToLower(i.Name) < strings.ToLower(j.Name)
|
||||
}
|
||||
|
||||
func reverse(less func(i, j int) bool) func(i, j int) bool {
|
||||
return func(i, j int) bool {
|
||||
return !less(i, j)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
)
|
||||
|
||||
// ChangeOwnPassword implements the Service interface. It allows the user to change
|
||||
// its own password
|
||||
func (g Graph) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
u, ok := revactx.ContextGetUser(ctx)
|
||||
if !ok {
|
||||
g.logger.Error().Msg("user not in context")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "user not in context")
|
||||
return
|
||||
}
|
||||
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
_, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
g.logger.Err(err).Interface("query", r.URL.Query()).Msg("query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
cpw := libregraph.NewPasswordChangeWithDefaults()
|
||||
err = StrictJSONUnmarshal(r.Body, cpw)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
currentPw := cpw.GetCurrentPassword()
|
||||
if currentPw == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "current password cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
newPw := cpw.GetNewPassword()
|
||||
if newPw == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "new password cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
if newPw == currentPw {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "new password must be different from current password")
|
||||
return
|
||||
}
|
||||
|
||||
authReq := &gateway.AuthenticateRequest{
|
||||
Type: "basic",
|
||||
ClientId: u.Username,
|
||||
ClientSecret: currentPw,
|
||||
}
|
||||
client, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select next gateway client, aborting")
|
||||
return
|
||||
}
|
||||
authRes, err := client.Authenticate(r.Context(), authReq)
|
||||
if err != nil {
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
switch authRes.Status.Code {
|
||||
case cs3rpc.Code_CODE_OK:
|
||||
break
|
||||
case cs3rpc.Code_CODE_UNAUTHENTICATED, cs3rpc.Code_CODE_PERMISSION_DENIED:
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "wrong current password")
|
||||
return
|
||||
default:
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "password change failed")
|
||||
return
|
||||
}
|
||||
|
||||
newPwProfile := libregraph.NewPasswordProfile()
|
||||
newPwProfile.SetPassword(newPw)
|
||||
changes := libregraph.NewUserUpdate()
|
||||
changes.SetPasswordProfile(*newPwProfile)
|
||||
_, err = g.identityBackend.UpdateUser(ctx, u.Id.OpaqueId, *changes)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "password change failed")
|
||||
g.logger.Debug().Err(err).Str("userid", u.Id.OpaqueId).Msg("failed to update user password")
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := revactx.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(
|
||||
ctx,
|
||||
events.UserFeatureChanged{
|
||||
Executant: currentUser.Id,
|
||||
UserID: u.Id.OpaqueId,
|
||||
Features: []events.UserFeature{
|
||||
{Name: "password", Value: "***"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/services/graph/mocks"
|
||||
"github.com/qsfera/server/services/graph/pkg/config"
|
||||
"github.com/qsfera/server/services/graph/pkg/config/defaults"
|
||||
"github.com/qsfera/server/services/graph/pkg/identity"
|
||||
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
|
||||
service "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
)
|
||||
|
||||
var _ = Describe("Users changing their own password", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
ldapClient *identitymocks.Client
|
||||
ldapConfig config.LDAP
|
||||
identityBackend identity.Backend
|
||||
eventsPublisher mocks.Publisher
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
user *userv1beta1.User
|
||||
err error
|
||||
)
|
||||
|
||||
JustBeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
|
||||
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
|
||||
ldapClient = mockedLDAPClient()
|
||||
|
||||
ldapConfig = config.LDAP{
|
||||
WriteEnabled: true,
|
||||
UserDisplayNameAttribute: "displayName",
|
||||
UserNameAttribute: "uid",
|
||||
UserEmailAttribute: "mail",
|
||||
UserIDAttribute: "qsferaUUID",
|
||||
UserSearchScope: "sub",
|
||||
GroupNameAttribute: "cn",
|
||||
GroupIDAttribute: "qsferaUUID",
|
||||
GroupSearchScope: "sub",
|
||||
}
|
||||
logger := log.NewLogger()
|
||||
identityBackend, err = identity.NewLDAPBackend(ldapClient, ldapConfig, &logger)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
eventsPublisher = mocks.Publisher{}
|
||||
|
||||
var err error
|
||||
svc, err = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.WithIdentityBackend(identityBackend),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
user = &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "user",
|
||||
},
|
||||
}
|
||||
ctx = revactx.ContextSetUser(ctx, user)
|
||||
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
})
|
||||
|
||||
It("fails if no user in context", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/changePassword", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
svc.ChangeOwnPassword(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
|
||||
DescribeTable("changing the password",
|
||||
func(current string, newpw string, authresult string, expected int) {
|
||||
switch authresult {
|
||||
case "error":
|
||||
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(nil, errors.New("fail"))
|
||||
case "deny":
|
||||
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
|
||||
Status: status.NewPermissionDenied(ctx, errors.New("wrong password"), "wrong password"),
|
||||
Token: "authtoken",
|
||||
}, nil)
|
||||
default:
|
||||
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Token: "authtoken",
|
||||
}, nil)
|
||||
}
|
||||
cpw := libregraph.NewPasswordChangeWithDefaults()
|
||||
cpw.SetCurrentPassword(current)
|
||||
cpw.SetNewPassword(newpw)
|
||||
body, _ := json.Marshal(cpw)
|
||||
b := bytes.NewBuffer(body)
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/me/changePassword", b).WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
svc.ChangeOwnPassword(rr, r)
|
||||
Expect(rr.Code).To(Equal(expected))
|
||||
},
|
||||
Entry("fails when current password is empty", "", "newpassword", "", http.StatusBadRequest),
|
||||
Entry("fails when new password is empty", "currentpassword", "", "", http.StatusBadRequest),
|
||||
Entry("fails when current and new password are equal", "password", "password", "", http.StatusBadRequest),
|
||||
Entry("fails authentication with current password errors", "currentpassword", "newpassword", "error", http.StatusInternalServerError),
|
||||
Entry("fails when current password is wrong", "currentpassword", "newpassword", "deny", http.StatusBadRequest),
|
||||
Entry("succeeds when current password is correct", "currentpassword", "newpassword", "", http.StatusNoContent),
|
||||
)
|
||||
})
|
||||
|
||||
func mockedLDAPClient() *identitymocks.Client {
|
||||
lm := &identitymocks.Client{}
|
||||
|
||||
userEntry := ldap.NewEntry("uid=test", map[string][]string{
|
||||
"uid": {"test"},
|
||||
"displayName": {"test"},
|
||||
"mail": {"test@example.org"},
|
||||
})
|
||||
|
||||
lm.On("Search", mock.Anything, mock.Anything, mock.Anything, mock.Anything,
|
||||
mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(
|
||||
&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}},
|
||||
nil)
|
||||
|
||||
mr := ldap.NewModifyRequest("uid=test", nil)
|
||||
mr.Changes = []ldap.Change{
|
||||
{
|
||||
Operation: ldap.ReplaceAttribute,
|
||||
Modification: ldap.PartialAttribute{
|
||||
Type: "userPassword",
|
||||
Vals: []string{"newpassword"},
|
||||
},
|
||||
},
|
||||
}
|
||||
lm.On("Modify", mr).Return(nil)
|
||||
return lm
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
ehmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/eventhistory/v0"
|
||||
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rhttp"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
_backupFileName = "personal_data_export.json"
|
||||
|
||||
// TokenTransportHeader holds the header key for the reva transfer token
|
||||
TokenTransportHeader = "X-Reva-Transfer"
|
||||
)
|
||||
|
||||
// Marshaller is the common interface for a marshaller
|
||||
type Marshaller func(any) ([]byte, error)
|
||||
|
||||
// ExportPersonalDataRequest is the body of the request
|
||||
type ExportPersonalDataRequest struct {
|
||||
StorageLocation string `json:"storageLocation"`
|
||||
}
|
||||
|
||||
func (g Graph) getPersonalSpace(ctx context.Context, u *user.UserId) (*provider.StorageSpace, error) {
|
||||
|
||||
// we cannot assume the space id is the same as the user id, so we have to look up his personal space first
|
||||
lReq := &storageprovider.ListStorageSpacesRequest{
|
||||
Filters: []*storageprovider.ListStorageSpacesRequest_Filter{
|
||||
{
|
||||
Type: storageprovider.ListStorageSpacesRequest_Filter_TYPE_OWNER,
|
||||
Term: &storageprovider.ListStorageSpacesRequest_Filter_Owner{
|
||||
Owner: u,
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: storageprovider.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE,
|
||||
Term: &storageprovider.ListStorageSpacesRequest_Filter_SpaceType{
|
||||
SpaceType: "personal",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := gatewayClient.ListStorageSpaces(ctx, lReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Status.Code != rpc.Code_CODE_OK {
|
||||
return nil, errorcode.FromCS3Status(res.Status, fmt.Errorf("could not list storage spaces"))
|
||||
}
|
||||
if len(res.GetStorageSpaces()) == 0 {
|
||||
return nil, fmt.Errorf("could not find personal space for user %s", u.GetOpaqueId())
|
||||
}
|
||||
|
||||
return res.GetStorageSpaces()[0], nil
|
||||
}
|
||||
|
||||
// ExportPersonalData exports all personal data the system holds
|
||||
func (g Graph) ExportPersonalData(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
u := revactx.ContextMustGetUser(ctx)
|
||||
if reqUserID := chi.URLParam(r, "userID"); reqUserID != u.GetId().GetOpaqueId() {
|
||||
g.logger.Info().Msg("uid mismatch")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("personal data export for other users are not permitted"))
|
||||
return
|
||||
}
|
||||
// Get location from request
|
||||
loc := getLocation(r)
|
||||
|
||||
// prepare marshaller
|
||||
var marsh Marshaller
|
||||
switch filepath.Ext(loc) {
|
||||
default:
|
||||
g.logger.Info().Str("path", loc).Msg("invalid location")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("only json format is supported for personal data export"))
|
||||
return
|
||||
case ".json":
|
||||
marsh = json.Marshal
|
||||
}
|
||||
|
||||
personalSpace, err := g.getPersonalSpace(ctx, u.GetId())
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not get personal space")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not get personal space, aborting")
|
||||
return
|
||||
}
|
||||
|
||||
ref := &provider.Reference{
|
||||
ResourceId: personalSpace.GetRoot(),
|
||||
Path: loc,
|
||||
}
|
||||
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select next gateway client, aborting")
|
||||
return
|
||||
}
|
||||
|
||||
// touch file
|
||||
if err := mustTouchFile(ctx, ref, gatewayClient); err != nil {
|
||||
g.logger.Error().Err(err).Msg("error touching file")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// go start gathering
|
||||
go g.GatherPersonalData(u, ref, r.Header.Get(revactx.TokenHeader), marsh)
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// GatherPersonalData will all gather all personal data of the user and save it to a file in the users personal space
|
||||
func (g Graph) GatherPersonalData(usr *user.User, ref *provider.Reference, token string, marsh Marshaller) {
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
return
|
||||
}
|
||||
|
||||
// the context might already be cancelled. We need to impersonate the acting user again
|
||||
ctx, err := utils.GetServiceUserContext(g.config.ServiceAccount.ServiceAccountID, gatewayClient, g.config.ServiceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Str("userID", usr.GetId().GetOpaqueId()).Msg("cannot impersonate user")
|
||||
}
|
||||
// create data
|
||||
data := make(map[string]any)
|
||||
|
||||
// reva user
|
||||
data["user"] = usr
|
||||
|
||||
// Check if we have a keycloak client, and if so, get the keycloak export.
|
||||
if ctx != nil && g.keycloakClient != nil {
|
||||
kcd, err := g.keycloakClient.GetPIIReport(ctx, g.config.Keycloak.UserRealm, usr.GetUsername())
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Str("userID", usr.GetId().GetOpaqueId()).Msg("cannot get keycloak personal data")
|
||||
}
|
||||
data["keycloak"] = kcd
|
||||
}
|
||||
|
||||
// get event data
|
||||
if ctx != nil && g.historyClient != nil {
|
||||
resp, err := g.historyClient.GetEventsForUser(ctx, &ehsvc.GetEventsForUserRequest{UserID: usr.GetId().GetOpaqueId()})
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Str("userID", usr.GetId().GetOpaqueId()).Msg("cannot get event personal data")
|
||||
}
|
||||
data["events"] = convertEvents(resp.GetEvents())
|
||||
}
|
||||
|
||||
// marshal
|
||||
by, err := marsh(data)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("cannot marshal personal user data")
|
||||
return
|
||||
}
|
||||
|
||||
// upload
|
||||
var errmsg string
|
||||
if err := g.upload(usr, by, ref, token); err != nil {
|
||||
g.logger.Error().Err(err).Msg("failed uploading personal data export")
|
||||
errmsg = err.Error()
|
||||
}
|
||||
|
||||
if err := events.Publish(ctx, g.eventsPublisher, events.PersonalDataExtracted{
|
||||
Executant: usr.GetId(),
|
||||
Timestamp: utils.TSNow(),
|
||||
ErrorMsg: errmsg,
|
||||
}); err != nil {
|
||||
g.logger.Error().Err(err).Msg("cannot publish event")
|
||||
}
|
||||
}
|
||||
|
||||
func (g Graph) upload(u *user.User, data []byte, ref *provider.Reference, th string) error {
|
||||
uReq := &provider.InitiateFileUploadRequest{
|
||||
Ref: ref,
|
||||
Opaque: utils.AppendPlainToOpaque(nil, "Upload-Length", strconv.FormatUint(uint64(len(data)), 10)),
|
||||
}
|
||||
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err := utils.GetServiceUserContext(g.config.ServiceAccount.ServiceAccountID, gatewayClient, g.config.ServiceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = revactx.ContextSetToken(ctx, th)
|
||||
uRes, err := client.InitiateFileUpload(ctx, uReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if uRes.Status.Code != rpc.Code_CODE_OK {
|
||||
return fmt.Errorf("wrong status code while initiating upload: %s", uRes.GetStatus().GetMessage())
|
||||
}
|
||||
|
||||
var uploadEP, uploadToken string
|
||||
for _, p := range uRes.Protocols {
|
||||
if p.Protocol == "simple" {
|
||||
uploadEP, uploadToken = p.UploadEndpoint, p.Token
|
||||
}
|
||||
}
|
||||
|
||||
httpUploadReq, err := rhttp.NewRequest(ctx, "PUT", uploadEP, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpUploadReq.Header.Set(TokenTransportHeader, uploadToken)
|
||||
|
||||
httpUploadRes, err := rhttp.GetHTTPClient(rhttp.Insecure(true)).Do(httpUploadReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer httpUploadRes.Body.Close()
|
||||
if httpUploadRes.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("wrong status uploading file: %d", httpUploadRes.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// touches the file, creating folders if necessary
|
||||
func mustTouchFile(ctx context.Context, ref *provider.Reference, gwc gateway.GatewayAPIClient) error {
|
||||
if err := touchFile(ctx, ref, gwc); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := createFolders(ctx, ref, gwc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return touchFile(ctx, ref, gwc)
|
||||
}
|
||||
|
||||
func touchFile(ctx context.Context, ref *provider.Reference, gwc gateway.GatewayAPIClient) error {
|
||||
resp, err := gwc.TouchFile(ctx, &provider.TouchFileRequest{
|
||||
Opaque: utils.AppendPlainToOpaque(nil, "markprocessing", "true"),
|
||||
Ref: ref,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
return fmt.Errorf("unexpected statuscode while touching file: %d %s", resp.GetStatus().GetCode(), resp.GetStatus().GetMessage())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createFolders(ctx context.Context, ref *provider.Reference, gwc gateway.GatewayAPIClient) error {
|
||||
var paths []string
|
||||
p := filepath.Dir(ref.GetPath())
|
||||
for p != "." {
|
||||
paths = append([]string{p}, paths...)
|
||||
p = filepath.Dir(p)
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
r := &provider.Reference{ResourceId: ref.GetResourceId(), Path: p}
|
||||
resp, err := gwc.CreateContainer(ctx, &provider.CreateContainerRequest{Ref: r})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
code := resp.GetStatus().GetCode()
|
||||
if code != rpc.Code_CODE_OK && code != rpc.Code_CODE_ALREADY_EXISTS {
|
||||
return fmt.Errorf("unexpected statuscode while creating folder: %d %s", code, resp.GetStatus().GetMessage())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLocation(r *http.Request) string {
|
||||
// from body
|
||||
var req ExportPersonalDataRequest
|
||||
if b, err := io.ReadAll(r.Body); err == nil {
|
||||
if err := json.Unmarshal(b, &req); err == nil && req.StorageLocation != "" {
|
||||
return req.StorageLocation
|
||||
}
|
||||
}
|
||||
|
||||
// from header?
|
||||
|
||||
return _backupFileName
|
||||
}
|
||||
|
||||
// we want the events to look nice in the file, don't we?
|
||||
func convertEvents(evs []*ehmsg.Event) []map[string]any {
|
||||
out := make([]map[string]any, len(evs))
|
||||
for i, e := range evs {
|
||||
var content map[string]any
|
||||
_ = json.Unmarshal(e.GetEvent(), &content)
|
||||
out[i] = map[string]any{
|
||||
"id": e.GetId(),
|
||||
"type": e.GetType(),
|
||||
"event": content,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
|
||||
)
|
||||
|
||||
// GetRoleDefinitions a list of permission roles than can be used when sharing with users or groups
|
||||
func (g Graph) GetRoleDefinitions(w http.ResponseWriter, r *http.Request) {
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, g.availableRoles)
|
||||
}
|
||||
|
||||
// GetRoleDefinition a permission role than can be used when sharing with users or groups
|
||||
func (g Graph) GetRoleDefinition(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
roleID, err := url.PathUnescape(chi.URLParam(r, "roleID"))
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("roleID", chi.URLParam(r, "roleID")).Msg("could not get roleID: unescaping is failed")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping role id failed")
|
||||
return
|
||||
}
|
||||
role, err := unifiedrole.GetRole(unifiedrole.RoleFilterIDs(roleID))
|
||||
if err != nil {
|
||||
logger.Debug().Str("roleID", roleID).Msg("could not get role: not found")
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, role)
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
ldapv3 "github.com/go-ldap/ldap/v3"
|
||||
"github.com/jellydator/ttlcache/v3"
|
||||
"github.com/qsfera/server/services/graph/pkg/identity/cache"
|
||||
"github.com/riandyrn/otelchi"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
|
||||
"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/store"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils/ldap"
|
||||
|
||||
ocldap "github.com/qsfera/server/pkg/ldap"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/roles"
|
||||
"github.com/qsfera/server/pkg/service/grpc"
|
||||
"github.com/qsfera/server/pkg/tracing"
|
||||
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
"github.com/qsfera/server/services/graph/pkg/identity"
|
||||
graphm "github.com/qsfera/server/services/graph/pkg/middleware"
|
||||
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
|
||||
)
|
||||
|
||||
const (
|
||||
// HeaderPurge defines the header name for the purge header.
|
||||
HeaderPurge = "Purge"
|
||||
displayNameAttr = "displayName"
|
||||
)
|
||||
|
||||
// Service defines the service handlers.
|
||||
type Service interface { //nolint:interfacebloat
|
||||
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
ListApplications(w http.ResponseWriter, r *http.Request)
|
||||
GetApplication(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
GetMe(w http.ResponseWriter, r *http.Request)
|
||||
GetUsers(w http.ResponseWriter, r *http.Request)
|
||||
GetUser(w http.ResponseWriter, r *http.Request)
|
||||
PostUser(w http.ResponseWriter, r *http.Request)
|
||||
DeleteUser(w http.ResponseWriter, r *http.Request)
|
||||
PatchUser(w http.ResponseWriter, r *http.Request)
|
||||
ChangeOwnPassword(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
ListAppRoleAssignments(w http.ResponseWriter, r *http.Request)
|
||||
CreateAppRoleAssignment(w http.ResponseWriter, r *http.Request)
|
||||
DeleteAppRoleAssignment(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
GetGroups(w http.ResponseWriter, r *http.Request)
|
||||
GetGroup(w http.ResponseWriter, r *http.Request)
|
||||
PostGroup(w http.ResponseWriter, r *http.Request)
|
||||
PatchGroup(w http.ResponseWriter, r *http.Request)
|
||||
DeleteGroup(w http.ResponseWriter, r *http.Request)
|
||||
GetGroupMembers(w http.ResponseWriter, r *http.Request)
|
||||
PostGroupMember(w http.ResponseWriter, r *http.Request)
|
||||
DeleteGroupMember(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
GetEducationSchools(w http.ResponseWriter, r *http.Request)
|
||||
GetEducationSchool(w http.ResponseWriter, r *http.Request)
|
||||
PostEducationSchool(w http.ResponseWriter, r *http.Request)
|
||||
PatchEducationSchool(w http.ResponseWriter, r *http.Request)
|
||||
DeleteEducationSchool(w http.ResponseWriter, r *http.Request)
|
||||
GetEducationSchoolUsers(w http.ResponseWriter, r *http.Request)
|
||||
PostEducationSchoolUser(w http.ResponseWriter, r *http.Request)
|
||||
DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Request)
|
||||
GetEducationSchoolClasses(w http.ResponseWriter, r *http.Request)
|
||||
PostEducationSchoolClass(w http.ResponseWriter, r *http.Request)
|
||||
DeleteEducationSchoolClass(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
GetEducationClasses(w http.ResponseWriter, r *http.Request)
|
||||
GetEducationClass(w http.ResponseWriter, r *http.Request)
|
||||
PostEducationClass(w http.ResponseWriter, r *http.Request)
|
||||
PatchEducationClass(w http.ResponseWriter, r *http.Request)
|
||||
DeleteEducationClass(w http.ResponseWriter, r *http.Request)
|
||||
GetEducationClassMembers(w http.ResponseWriter, r *http.Request)
|
||||
PostEducationClassMember(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
GetEducationUsers(w http.ResponseWriter, r *http.Request)
|
||||
GetEducationUser(w http.ResponseWriter, r *http.Request)
|
||||
PostEducationUser(w http.ResponseWriter, r *http.Request)
|
||||
DeleteEducationUser(w http.ResponseWriter, r *http.Request)
|
||||
PatchEducationUser(w http.ResponseWriter, r *http.Request)
|
||||
DeleteEducationClassMember(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
GetEducationClassTeachers(w http.ResponseWriter, r *http.Request)
|
||||
PostEducationClassTeacher(w http.ResponseWriter, r *http.Request)
|
||||
DeleteEducationClassTeacher(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
GetDrivesV1(w http.ResponseWriter, r *http.Request)
|
||||
GetDrivesV1Beta1(w http.ResponseWriter, r *http.Request)
|
||||
GetSingleDrive(w http.ResponseWriter, r *http.Request)
|
||||
GetAllDrivesV1(w http.ResponseWriter, r *http.Request)
|
||||
GetAllDrivesV1Beta1(w http.ResponseWriter, r *http.Request)
|
||||
CreateDrive(w http.ResponseWriter, r *http.Request)
|
||||
UpdateDrive(w http.ResponseWriter, r *http.Request)
|
||||
DeleteDrive(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
GetSharedByMe(w http.ResponseWriter, r *http.Request)
|
||||
ListSharedWithMe(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
GetRootDriveChildren(w http.ResponseWriter, r *http.Request)
|
||||
GetDriveItem(w http.ResponseWriter, r *http.Request)
|
||||
GetDriveItemChildren(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
CreateUploadSession(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
GetTags(w http.ResponseWriter, r *http.Request)
|
||||
AssignTags(w http.ResponseWriter, r *http.Request)
|
||||
UnassignTags(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// NewService returns a service implementation for Service.
|
||||
func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
|
||||
options := newOptions(opts...)
|
||||
|
||||
m := chi.NewMux()
|
||||
m.Use(options.Middleware...)
|
||||
m.Use(
|
||||
otelchi.Middleware(
|
||||
"graph",
|
||||
otelchi.WithChiRoutes(m),
|
||||
otelchi.WithTracerProvider(options.TraceProvider),
|
||||
otelchi.WithPropagators(tracing.GetPropagator()),
|
||||
),
|
||||
)
|
||||
|
||||
spacePropertiesCache := ttlcache.New(
|
||||
ttlcache.WithTTL[string, any](
|
||||
time.Duration(options.Config.Spaces.ExtendedSpacePropertiesCacheTTL),
|
||||
),
|
||||
ttlcache.WithDisableTouchOnHit[string, any](),
|
||||
)
|
||||
go spacePropertiesCache.Start()
|
||||
|
||||
identityCache := cache.NewIdentityCache(
|
||||
cache.IdentityCacheWithGatewaySelector(options.GatewaySelector),
|
||||
cache.IdentityCacheWithUsersTTL(time.Duration(options.Config.Spaces.UsersCacheTTL)),
|
||||
cache.IdentityCacheWithGroupsTTL(time.Duration(options.Config.Spaces.GroupsCacheTTL)),
|
||||
)
|
||||
|
||||
publicBaseURL, err := url.Parse(options.Config.Spaces.WebDavBase)
|
||||
if err != nil {
|
||||
return Graph{}, fmt.Errorf("could not parse graph.spaces.webdav_base: %w", err)
|
||||
}
|
||||
|
||||
baseGraphService := BaseGraphService{
|
||||
logger: &options.Logger,
|
||||
identityCache: identityCache,
|
||||
gatewaySelector: options.GatewaySelector,
|
||||
config: options.Config,
|
||||
availableRoles: unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(options.Config.UnifiedRoles.AvailableRoles...)),
|
||||
publicBaseURL: publicBaseURL,
|
||||
}
|
||||
|
||||
drivesDriveItemService, err := NewDrivesDriveItemService(options.Logger, options.GatewaySelector)
|
||||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
|
||||
drivesDriveItemApi, err := NewDrivesDriveItemApi(drivesDriveItemService, baseGraphService, options.Logger)
|
||||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
|
||||
driveItemPermissionsService, err := NewDriveItemPermissionsService(options.Logger, options.GatewaySelector, identityCache, options.Config)
|
||||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
|
||||
driveItemPermissionsApi, err := NewDriveItemPermissionsApi(driveItemPermissionsService, options.Logger, options.Config)
|
||||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
|
||||
usersUserProfilePhotoApi, err := NewUsersUserProfilePhotoApi(options.UserProfilePhotoService, options.Logger)
|
||||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
|
||||
svc := Graph{
|
||||
BaseGraphService: baseGraphService,
|
||||
mux: m,
|
||||
specialDriveItemsCache: spacePropertiesCache,
|
||||
eventsPublisher: options.EventsPublisher,
|
||||
eventsConsumer: options.EventsConsumer,
|
||||
searchService: options.SearchService,
|
||||
identityEducationBackend: options.IdentityEducationBackend,
|
||||
keycloakClient: options.KeycloakClient,
|
||||
historyClient: options.EventHistoryClient,
|
||||
traceProvider: options.TraceProvider,
|
||||
valueService: options.ValueService,
|
||||
natskv: options.NatsKeyValue,
|
||||
}
|
||||
|
||||
if err := setIdentityBackends(options, &svc); err != nil {
|
||||
return svc, err
|
||||
}
|
||||
|
||||
if options.PermissionService == nil {
|
||||
grpcClient, err := grpc.NewClient(append(grpc.GetClientOptions(options.Config.GRPCClientTLS), grpc.WithTraceProvider(options.TraceProvider))...)
|
||||
if err != nil {
|
||||
return svc, err
|
||||
}
|
||||
svc.permissionsService = settingssvc.NewPermissionService("qsfera.api.settings", grpcClient)
|
||||
} else {
|
||||
svc.permissionsService = options.PermissionService
|
||||
}
|
||||
|
||||
svc.roleService = options.RoleService
|
||||
|
||||
roleManager := options.RoleManager
|
||||
if roleManager == nil {
|
||||
storeOptions := []microstore.Option{
|
||||
store.Store(options.Config.Cache.Store),
|
||||
store.TTL(options.Config.Cache.TTL),
|
||||
microstore.Nodes(options.Config.Cache.Nodes...),
|
||||
microstore.Database(options.Config.Cache.Database),
|
||||
microstore.Table(options.Config.Cache.Table),
|
||||
store.DisablePersistence(options.Config.Cache.DisablePersistence),
|
||||
store.Authentication(options.Config.Cache.AuthUsername, options.Config.Cache.AuthPassword),
|
||||
}
|
||||
m := roles.NewManager(
|
||||
roles.StoreOptions(storeOptions),
|
||||
roles.Logger(options.Logger),
|
||||
roles.RoleService(options.RoleService),
|
||||
)
|
||||
roleManager = &m
|
||||
}
|
||||
|
||||
var requireAdmin func(http.Handler) http.Handler
|
||||
if options.RequireAdminMiddleware == nil {
|
||||
requireAdmin = graphm.RequireAdmin(roleManager, options.Logger)
|
||||
} else {
|
||||
requireAdmin = options.RequireAdminMiddleware
|
||||
}
|
||||
|
||||
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
|
||||
r.Use(middleware.StripSlashes)
|
||||
|
||||
r.Route("/v1beta1", func(r chi.Router) {
|
||||
r.Route("/me", func(r chi.Router) {
|
||||
r.Get("/drives", svc.GetDrives(APIVersion_1_Beta_1))
|
||||
r.Route("/drive", func(r chi.Router) {
|
||||
r.Get("/sharedByMe", svc.GetSharedByMe)
|
||||
r.Get("/sharedWithMe", svc.ListSharedWithMe)
|
||||
})
|
||||
})
|
||||
r.Route("/drives", func(r chi.Router) {
|
||||
r.Get("/", svc.GetAllDrives(APIVersion_1_Beta_1))
|
||||
r.Route("/{driveID}", func(r chi.Router) {
|
||||
r.Route("/root", func(r chi.Router) {
|
||||
r.Post("/children", drivesDriveItemApi.CreateDriveItem)
|
||||
r.Post("/invite", driveItemPermissionsApi.SpaceRootInvite)
|
||||
r.Post("/createLink", driveItemPermissionsApi.CreateSpaceRootLink)
|
||||
r.Route("/permissions", func(r chi.Router) {
|
||||
r.Get("/", driveItemPermissionsApi.ListSpaceRootPermissions)
|
||||
r.Route("/{permissionID}", func(r chi.Router) {
|
||||
r.Delete("/", driveItemPermissionsApi.DeleteSpaceRootPermission)
|
||||
r.Patch("/", driveItemPermissionsApi.UpdateSpaceRootPermission)
|
||||
r.Post("/setPassword", driveItemPermissionsApi.SetSpaceRootLinkPassword)
|
||||
})
|
||||
})
|
||||
})
|
||||
r.Route("/items/{itemID}", func(r chi.Router) {
|
||||
r.Get("/", drivesDriveItemApi.GetDriveItem)
|
||||
r.Patch("/", drivesDriveItemApi.UpdateDriveItem)
|
||||
r.Delete("/", drivesDriveItemApi.DeleteDriveItem)
|
||||
r.Post("/invite", driveItemPermissionsApi.Invite)
|
||||
r.Post("/createLink", driveItemPermissionsApi.CreateLink)
|
||||
r.Route("/permissions", func(r chi.Router) {
|
||||
r.Get("/", driveItemPermissionsApi.ListPermissions)
|
||||
r.Route("/{permissionID}", func(r chi.Router) {
|
||||
r.Delete("/", driveItemPermissionsApi.DeletePermission)
|
||||
r.Patch("/", driveItemPermissionsApi.UpdatePermission)
|
||||
r.Post("/setPassword", driveItemPermissionsApi.SetLinkPassword)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
r.Route("/roleManagement/permissions/roleDefinitions", func(r chi.Router) {
|
||||
r.Get("/", svc.GetRoleDefinitions)
|
||||
r.Get("/{roleID}", svc.GetRoleDefinition)
|
||||
})
|
||||
})
|
||||
r.Route("/v1.0", func(r chi.Router) {
|
||||
r.Route("/extensions/org.libregraph", func(r chi.Router) {
|
||||
r.Get("/tags", svc.GetTags)
|
||||
r.Put("/tags", svc.AssignTags)
|
||||
r.Delete("/tags", svc.UnassignTags)
|
||||
})
|
||||
r.Route("/applications", func(r chi.Router) {
|
||||
r.Get("/", svc.ListApplications)
|
||||
r.Get("/{applicationID}", svc.GetApplication)
|
||||
})
|
||||
r.Route("/me", func(r chi.Router) {
|
||||
r.Get("/", svc.GetMe)
|
||||
r.Patch("/", svc.PatchMe)
|
||||
r.Route("/drive", func(r chi.Router) {
|
||||
r.Get("/", svc.GetUserDrive)
|
||||
r.Get("/root/children", svc.GetRootDriveChildren)
|
||||
r.Post("/items/{itemID}/follow", svc.FollowDriveItem)
|
||||
r.Delete("/following/{itemID}", svc.UnfollowDriveItem)
|
||||
})
|
||||
r.Get("/drives", svc.GetDrives(APIVersion_1))
|
||||
r.Post("/changePassword", svc.ChangeOwnPassword)
|
||||
r.Route("/photo/$value", func(r chi.Router) {
|
||||
r.Get("/", usersUserProfilePhotoApi.GetProfilePhoto(GetUserIDFromCTX))
|
||||
r.Put("/", usersUserProfilePhotoApi.UpsertProfilePhoto(GetUserIDFromCTX))
|
||||
r.Patch("/", usersUserProfilePhotoApi.UpsertProfilePhoto(GetUserIDFromCTX))
|
||||
r.Delete("/", usersUserProfilePhotoApi.DeleteProfilePhoto(GetUserIDFromCTX))
|
||||
})
|
||||
})
|
||||
r.Route("/users", func(r chi.Router) {
|
||||
r.Get("/", svc.GetUsers)
|
||||
r.With(requireAdmin).Post("/", svc.PostUser)
|
||||
r.Route("/{userID}", func(r chi.Router) {
|
||||
r.Get("/", svc.GetUser)
|
||||
r.Get("/drive", svc.GetUserDrive)
|
||||
r.Post("/exportPersonalData", svc.ExportPersonalData)
|
||||
r.Route("/photo/$value", func(r chi.Router) {
|
||||
r.Get("/", usersUserProfilePhotoApi.GetProfilePhoto(GetSlugValue("userID")))
|
||||
})
|
||||
r.With(requireAdmin).Delete("/", svc.DeleteUser)
|
||||
r.With(requireAdmin).Patch("/", svc.PatchUser)
|
||||
if svc.roleService != nil {
|
||||
r.With(requireAdmin).Route("/appRoleAssignments", func(r chi.Router) {
|
||||
r.Get("/", svc.ListAppRoleAssignments)
|
||||
r.Post("/", svc.CreateAppRoleAssignment)
|
||||
r.Delete("/{appRoleAssignmentID}", svc.DeleteAppRoleAssignment)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
r.Route("/groups", func(r chi.Router) {
|
||||
r.Get("/", svc.GetGroups)
|
||||
r.With(requireAdmin).Post("/", svc.PostGroup)
|
||||
r.Route("/{groupID}", func(r chi.Router) {
|
||||
r.Get("/", svc.GetGroup)
|
||||
r.With(requireAdmin).Delete("/", svc.DeleteGroup)
|
||||
r.With(requireAdmin).Patch("/", svc.PatchGroup)
|
||||
r.Route("/members", func(r chi.Router) {
|
||||
r.With(requireAdmin).Get("/", svc.GetGroupMembers)
|
||||
r.With(requireAdmin).Post("/$ref", svc.PostGroupMember)
|
||||
r.With(requireAdmin).Delete("/{memberID}/$ref", svc.DeleteGroupMember)
|
||||
})
|
||||
})
|
||||
})
|
||||
r.Route("/drives", func(r chi.Router) {
|
||||
r.Get("/", svc.GetAllDrives(APIVersion_1))
|
||||
r.Post("/", svc.CreateDrive)
|
||||
r.Route("/{driveID}", func(r chi.Router) {
|
||||
r.Patch("/", svc.UpdateDrive)
|
||||
r.Get("/", svc.GetSingleDrive)
|
||||
r.Delete("/", svc.DeleteDrive)
|
||||
r.Route("/items/{driveItemID}", func(r chi.Router) {
|
||||
r.Get("/", svc.GetDriveItem)
|
||||
r.Get("/children", svc.GetDriveItemChildren)
|
||||
r.Post("/createUploadSession", svc.CreateUploadSession)
|
||||
})
|
||||
})
|
||||
})
|
||||
r.With(requireAdmin).Route("/education", func(r chi.Router) {
|
||||
r.Route("/schools", func(r chi.Router) {
|
||||
r.Get("/", svc.GetEducationSchools)
|
||||
r.Post("/", svc.PostEducationSchool)
|
||||
r.Route("/{schoolID}", func(r chi.Router) {
|
||||
r.Get("/", svc.GetEducationSchool)
|
||||
r.Delete("/", svc.DeleteEducationSchool)
|
||||
r.Patch("/", svc.PatchEducationSchool)
|
||||
r.Route("/users", func(r chi.Router) {
|
||||
r.Get("/", svc.GetEducationSchoolUsers)
|
||||
r.Post("/$ref", svc.PostEducationSchoolUser)
|
||||
r.Delete("/{userID}/$ref", svc.DeleteEducationSchoolUser)
|
||||
})
|
||||
r.Route("/classes", func(r chi.Router) {
|
||||
r.Get("/", svc.GetEducationSchoolClasses)
|
||||
r.Post("/$ref", svc.PostEducationSchoolClass)
|
||||
r.Delete("/{classID}/$ref", svc.DeleteEducationSchoolClass)
|
||||
})
|
||||
})
|
||||
})
|
||||
r.Route("/users", func(r chi.Router) {
|
||||
r.Get("/", svc.GetEducationUsers)
|
||||
r.Post("/", svc.PostEducationUser)
|
||||
r.Route("/{userID}", func(r chi.Router) {
|
||||
r.Get("/", svc.GetEducationUser)
|
||||
r.Delete("/", svc.DeleteEducationUser)
|
||||
r.Patch("/", svc.PatchEducationUser)
|
||||
})
|
||||
})
|
||||
r.Route("/classes", func(r chi.Router) {
|
||||
r.Get("/", svc.GetEducationClasses)
|
||||
r.Post("/", svc.PostEducationClass)
|
||||
r.Route("/{classID}", func(r chi.Router) {
|
||||
r.Get("/", svc.GetEducationClass)
|
||||
r.Delete("/", svc.DeleteEducationClass)
|
||||
r.Patch("/", svc.PatchEducationClass)
|
||||
r.Route("/members", func(r chi.Router) {
|
||||
r.Get("/", svc.GetEducationClassMembers)
|
||||
r.Post("/$ref", svc.PostEducationClassMember)
|
||||
r.Delete("/{memberID}/$ref", svc.DeleteEducationClassMember)
|
||||
})
|
||||
r.Route("/teachers", func(r chi.Router) {
|
||||
r.Get("/", svc.GetEducationClassTeachers)
|
||||
r.Post("/$ref", svc.PostEducationClassTeacher)
|
||||
r.Delete("/{teacherID}/$ref", svc.DeleteEducationClassTeacher)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
_ = chi.Walk(m, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
|
||||
return nil
|
||||
})
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func setIdentityBackends(options Options, svc *Graph) error {
|
||||
if options.IdentityBackend == nil {
|
||||
switch options.Config.Identity.Backend {
|
||||
case "cs3":
|
||||
gatewaySelector, err := pool.GatewaySelector(
|
||||
options.Config.Reva.Address,
|
||||
append(
|
||||
options.Config.Reva.GetRevaOptions(),
|
||||
pool.WithRegistry(registry.GetRegistry()),
|
||||
pool.WithTracerProvider(options.TraceProvider),
|
||||
)...,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc.identityBackend = &identity.CS3{
|
||||
Config: options.Config.Reva,
|
||||
Logger: &options.Logger,
|
||||
GatewaySelector: gatewaySelector,
|
||||
}
|
||||
case "ldap":
|
||||
var err error
|
||||
|
||||
var tlsConf *tls.Config
|
||||
if options.Config.Identity.LDAP.Insecure {
|
||||
|
||||
// When insecure is set to true then we don't need a certificate.
|
||||
options.Config.Identity.LDAP.CACert = ""
|
||||
tlsConf = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
|
||||
//nolint:gosec // We need the ability to run with "insecure" (dev/testing)
|
||||
InsecureSkipVerify: options.Config.Identity.LDAP.Insecure,
|
||||
}
|
||||
}
|
||||
|
||||
if options.Config.Identity.LDAP.CACert != "" {
|
||||
if err := ocldap.WaitForCA(options.Logger,
|
||||
options.Config.Identity.LDAP.Insecure,
|
||||
options.Config.Identity.LDAP.CACert); err != nil {
|
||||
options.Logger.Fatal().Err(err).Msg("The configured LDAP CA cert does not exist")
|
||||
}
|
||||
if tlsConf == nil {
|
||||
tlsConf = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
certs := x509.NewCertPool()
|
||||
pemData, err := os.ReadFile(options.Config.Identity.LDAP.CACert)
|
||||
if err != nil {
|
||||
options.Logger.Error().Err(err).Msg("Error initializing LDAP Backend")
|
||||
return err
|
||||
}
|
||||
if !certs.AppendCertsFromPEM(pemData) {
|
||||
options.Logger.Error().Msg("Error initializing LDAP Backend. Adding CA cert failed")
|
||||
return err
|
||||
}
|
||||
tlsConf.RootCAs = certs
|
||||
}
|
||||
|
||||
conn := ldap.NewLDAPWithReconnect(
|
||||
ldap.Config{
|
||||
URI: options.Config.Identity.LDAP.URI,
|
||||
BindDN: options.Config.Identity.LDAP.BindDN,
|
||||
BindPassword: options.Config.Identity.LDAP.BindPassword,
|
||||
TLSConfig: tlsConf,
|
||||
},
|
||||
)
|
||||
conn.SetLogger(&options.Logger.Logger)
|
||||
lb, err := identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger)
|
||||
if err != nil {
|
||||
options.Logger.Error().Err(err).Msg("Error initializing LDAP Backend")
|
||||
return err
|
||||
}
|
||||
svc.identityBackend = lb
|
||||
if options.IdentityEducationBackend == nil {
|
||||
if options.Config.Identity.LDAP.EducationResourcesEnabled {
|
||||
svc.identityEducationBackend = lb
|
||||
} else {
|
||||
errEduBackend := &identity.ErrEducationBackend{}
|
||||
svc.identityEducationBackend = errEduBackend
|
||||
}
|
||||
}
|
||||
|
||||
disableMechanismType, err := identity.ParseDisableMechanismType(options.Config.Identity.LDAP.DisableUserMechanism)
|
||||
if err != nil {
|
||||
options.Logger.Error().Err(err).Msg("Error initializing LDAP Backend")
|
||||
return err
|
||||
}
|
||||
|
||||
if disableMechanismType == identity.DisableMechanismGroup {
|
||||
options.Logger.Info().Msg("LocalUserDisable is true, will create group if not exists")
|
||||
err := lb.CreateLDAPGroupByDN(options.Config.Identity.LDAP.LdapDisabledUsersGroupDN)
|
||||
if err != nil {
|
||||
isAnError := false
|
||||
var lerr *ldapv3.Error
|
||||
if errors.As(err, &lerr) {
|
||||
if lerr.ResultCode != ldapv3.LDAPResultEntryAlreadyExists {
|
||||
isAnError = true
|
||||
}
|
||||
} else {
|
||||
isAnError = true
|
||||
}
|
||||
|
||||
if isAnError {
|
||||
msg := "error adding group for disabling users"
|
||||
options.Logger.Error().Err(err).Str("local_user_disable", options.Config.Identity.LDAP.LdapDisabledUsersGroupDN).Msg(msg)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
err := fmt.Errorf("unknown identity backend: '%s'", options.Config.Identity.Backend)
|
||||
options.Logger.Err(err)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
svc.identityBackend = options.IdentityBackend
|
||||
}
|
||||
|
||||
return svc.StartListenForLogonEvents(options.Context, options.Logger)
|
||||
}
|
||||
|
||||
func (g *Graph) StartListenForLogonEvents(ctx context.Context, l log.Logger) error {
|
||||
if g.eventsConsumer == nil {
|
||||
return nil
|
||||
}
|
||||
var _registeredEvents = []events.Unmarshaller{
|
||||
events.UserSignedIn{},
|
||||
}
|
||||
evChannel, err := events.Consume(g.eventsConsumer, "graph", _registeredEvents...)
|
||||
if err != nil {
|
||||
l.Error().Err(err).Msg("cannot consume from nats")
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
for loop := true; loop; {
|
||||
select {
|
||||
case e := <-evChannel:
|
||||
switch ev := e.Event.(type) {
|
||||
default:
|
||||
l.Error().Interface("event", e).Msg("unhandled event")
|
||||
case events.UserSignedIn:
|
||||
if err := g.identityBackend.UpdateLastSignInDate(ctx, ev.Executant.OpaqueId, utils.TSToTime(ev.Timestamp)); err != nil {
|
||||
l.Error().Err(err).Str("userid", ev.Executant.OpaqueId).Msg("Error updating last sign in date")
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
l.Info().Msg("context cancelled")
|
||||
loop = false
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseHeaderPurge parses the 'Purge' header.
|
||||
// '1', 't', 'T', 'TRUE', 'true', 'True' are parsed as true
|
||||
// all other values are false.
|
||||
func parsePurgeHeader(h http.Header) bool {
|
||||
val := h.Get(HeaderPurge)
|
||||
|
||||
if b, err := strconv.ParseBool(val); err == nil {
|
||||
return b
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePurgeHeader(t *testing.T) {
|
||||
tests := map[string]bool{
|
||||
"": false,
|
||||
"f": false,
|
||||
"F": false,
|
||||
"anything": false,
|
||||
"t": true,
|
||||
"T": true,
|
||||
}
|
||||
|
||||
for input, expected := range tests {
|
||||
h := make(http.Header)
|
||||
h.Add(HeaderPurge, input)
|
||||
|
||||
if expected != parsePurgeHeader(h) {
|
||||
t.Errorf("parsePurgeHeader with input %s got %t expected %t", input, !expected, expected)
|
||||
}
|
||||
}
|
||||
|
||||
if h := make(http.Header); parsePurgeHeader(h) {
|
||||
t.Error("parsePurgeHeader without Purge header set got true expected false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/thumbnail"
|
||||
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
)
|
||||
|
||||
type driveItemsByResourceID map[string]libregraph.DriveItem
|
||||
|
||||
// GetSharedByMe implements the Service interface (/me/drives/sharedByMe endpoint)
|
||||
func (g Graph) GetSharedByMe(w http.ResponseWriter, r *http.Request) {
|
||||
g.logger.Debug().Msg("Calling GetRootDriveChildren")
|
||||
ctx := r.Context()
|
||||
|
||||
driveItems, err := g.listUserShares(ctx, nil, make(driveItemsByResourceID))
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if g.config.IncludeOCMSharees {
|
||||
driveItems, err = g.listOCMShares(ctx, nil, driveItems)
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
driveItems, err = g.listPublicShares(ctx, nil, driveItems)
|
||||
if err != nil {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
expand := r.URL.Query().Get("$expand")
|
||||
expandThumbnails := strings.Contains(expand, "thumbnails")
|
||||
if expandThumbnails {
|
||||
for k, item := range driveItems {
|
||||
mt := item.GetFile().MimeType
|
||||
if mt == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_, match := thumbnail.SupportedMimeTypes[*mt]
|
||||
if match {
|
||||
baseUrl := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail",
|
||||
g.config.Commons.QsferaURL,
|
||||
item.GetId())
|
||||
smallUrl := baseUrl + "&x=36&y=36"
|
||||
mediumUrl := baseUrl + "&x=48&y=48"
|
||||
largeUrl := baseUrl + "&x=96&y=96"
|
||||
|
||||
item.SetThumbnails([]libregraph.ThumbnailSet{
|
||||
{
|
||||
Small: &libregraph.Thumbnail{Url: &smallUrl},
|
||||
Medium: &libregraph.Thumbnail{Url: &mediumUrl},
|
||||
Large: &libregraph.Thumbnail{Url: &largeUrl},
|
||||
},
|
||||
})
|
||||
|
||||
driveItems[k] = item // assign modified item back to the map
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res := make([]libregraph.DriveItem, 0, len(driveItems))
|
||||
for _, v := range driveItems {
|
||||
res = append(res, v)
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &ListResponse{Value: res})
|
||||
}
|
||||
|
||||
func cs3StatusToErrCode(code rpc.Code) (errcode errorcode.ErrorCode) {
|
||||
switch code {
|
||||
case rpc.Code_CODE_UNAUTHENTICATED:
|
||||
errcode = errorcode.Unauthenticated
|
||||
case rpc.Code_CODE_PERMISSION_DENIED:
|
||||
errcode = errorcode.AccessDenied
|
||||
case rpc.Code_CODE_NOT_FOUND:
|
||||
errcode = errorcode.ItemNotFound
|
||||
case rpc.Code_CODE_LOCKED:
|
||||
errcode = errorcode.ItemIsLocked
|
||||
case rpc.Code_CODE_INVALID_ARGUMENT:
|
||||
errcode = errorcode.InvalidRequest
|
||||
case rpc.Code_CODE_FAILED_PRECONDITION:
|
||||
errcode = errorcode.InvalidRequest
|
||||
default:
|
||||
errcode = errorcode.GeneralException
|
||||
}
|
||||
return errcode
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
grouppb "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
|
||||
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/conversions"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/services/graph/mocks"
|
||||
"github.com/qsfera/server/services/graph/pkg/config"
|
||||
"github.com/qsfera/server/services/graph/pkg/config/defaults"
|
||||
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
|
||||
"github.com/qsfera/server/services/graph/pkg/linktype"
|
||||
service "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
|
||||
)
|
||||
|
||||
var _ = Describe("sharedbyme", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
eventsPublisher mocks.Publisher
|
||||
identityBackend *identitymocks.Backend
|
||||
driveItemCreateLink *libregraph.DriveItemCreateLink
|
||||
publicShare link.PublicShare
|
||||
|
||||
rr *httptest.ResponseRecorder
|
||||
)
|
||||
expiration := time.Now()
|
||||
|
||||
editorResourcePermissions := conversions.NewEditorRole().CS3ResourcePermissions()
|
||||
userShare := collaboration.Share{
|
||||
Id: &collaboration.ShareId{
|
||||
OpaqueId: "share-id",
|
||||
},
|
||||
ResourceId: &provider.ResourceId{
|
||||
StorageId: "storageid",
|
||||
SpaceId: "spaceid",
|
||||
OpaqueId: "opaqueid",
|
||||
},
|
||||
Grantee: &provider.Grantee{
|
||||
Type: provider.GranteeType_GRANTEE_TYPE_USER,
|
||||
Id: &provider.Grantee_UserId{
|
||||
UserId: &userpb.UserId{
|
||||
OpaqueId: "user-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
Permissions: &collaboration.SharePermissions{
|
||||
Permissions: editorResourcePermissions,
|
||||
},
|
||||
}
|
||||
groupShare := collaboration.Share{
|
||||
Id: &collaboration.ShareId{
|
||||
OpaqueId: "share-id",
|
||||
},
|
||||
ResourceId: &provider.ResourceId{
|
||||
StorageId: "storageid",
|
||||
SpaceId: "spaceid",
|
||||
OpaqueId: "opaqueid",
|
||||
},
|
||||
Grantee: &provider.Grantee{
|
||||
Type: provider.GranteeType_GRANTEE_TYPE_GROUP,
|
||||
Id: &provider.Grantee_GroupId{
|
||||
GroupId: &grouppb.GroupId{
|
||||
OpaqueId: "group-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
Permissions: &collaboration.SharePermissions{
|
||||
Permissions: editorResourcePermissions,
|
||||
},
|
||||
}
|
||||
userShareWithExpiration := collaboration.Share{
|
||||
Id: &collaboration.ShareId{
|
||||
OpaqueId: "expire-share-id",
|
||||
},
|
||||
ResourceId: &provider.ResourceId{
|
||||
StorageId: "storageid",
|
||||
SpaceId: "spaceid",
|
||||
OpaqueId: "expire-opaqueid",
|
||||
},
|
||||
Grantee: &provider.Grantee{
|
||||
Type: provider.GranteeType_GRANTEE_TYPE_USER,
|
||||
Id: &provider.Grantee_UserId{
|
||||
UserId: &userpb.UserId{
|
||||
OpaqueId: "user-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
Permissions: &collaboration.SharePermissions{
|
||||
Permissions: editorResourcePermissions,
|
||||
},
|
||||
Expiration: utils.TimeToTS(expiration),
|
||||
}
|
||||
driveItemCreateLink = &libregraph.DriveItemCreateLink{}
|
||||
|
||||
BeforeEach(func() {
|
||||
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
linkType, err := libregraph.NewSharingLinkTypeFromValue("view")
|
||||
Expect(err).To(BeNil())
|
||||
driveItemCreateLink.Type = linkType
|
||||
driveItemCreateLink.ExpirationDateTime = libregraph.PtrTime(time.Now().Add(time.Hour))
|
||||
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(*driveItemCreateLink, provider.ResourceType_RESOURCE_TYPE_CONTAINER)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
publicShare = link.PublicShare{
|
||||
Id: &link.PublicShareId{
|
||||
OpaqueId: "public-share-id",
|
||||
},
|
||||
Token: "public-share-token",
|
||||
ResourceId: &provider.ResourceId{
|
||||
StorageId: "storageid",
|
||||
SpaceId: "spaceid",
|
||||
OpaqueId: "public-share-opaqueid",
|
||||
},
|
||||
Permissions: &link.PublicSharePermissions{Permissions: permissions},
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
|
||||
gatewayClient.On("Stat",
|
||||
mock.Anything,
|
||||
mock.MatchedBy(
|
||||
func(req *provider.StatRequest) bool {
|
||||
return req.Ref.ResourceId.OpaqueId == userShareWithExpiration.ResourceId.OpaqueId
|
||||
})).
|
||||
Return(&provider.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &provider.ResourceInfo{
|
||||
Id: userShareWithExpiration.ResourceId,
|
||||
},
|
||||
}, nil)
|
||||
gatewayClient.On("Stat",
|
||||
mock.Anything,
|
||||
mock.MatchedBy(
|
||||
func(req *provider.StatRequest) bool {
|
||||
return req.Ref.ResourceId.OpaqueId == publicShare.ResourceId.OpaqueId
|
||||
})).
|
||||
Return(&provider.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &provider.ResourceInfo{
|
||||
Id: publicShare.ResourceId,
|
||||
},
|
||||
}, nil)
|
||||
gatewayClient.On("Stat",
|
||||
mock.Anything,
|
||||
mock.Anything).
|
||||
Return(&provider.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &provider.ResourceInfo{
|
||||
Id: userShare.ResourceId,
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
gatewayClient.On("GetUser",
|
||||
mock.Anything,
|
||||
mock.MatchedBy(func(req *userpb.GetUserRequest) bool {
|
||||
return req.UserId.OpaqueId == "user-id"
|
||||
})).
|
||||
Return(&userpb.GetUserResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
User: &userpb.User{
|
||||
Id: &userpb.UserId{
|
||||
Idp: "idp",
|
||||
OpaqueId: "user-id",
|
||||
},
|
||||
DisplayName: "User Name",
|
||||
},
|
||||
}, nil)
|
||||
gatewayClient.On("GetUser",
|
||||
mock.Anything,
|
||||
mock.Anything).
|
||||
Return(&userpb.GetUserResponse{
|
||||
Status: status.NewNotFound(ctx, "mock user not found"),
|
||||
User: nil,
|
||||
}, nil)
|
||||
gatewayClient.On("GetGroup",
|
||||
mock.Anything,
|
||||
mock.MatchedBy(func(req *grouppb.GetGroupRequest) bool {
|
||||
return req.GroupId.OpaqueId == "group-id"
|
||||
})).
|
||||
Return(&grouppb.GetGroupResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Group: &grouppb.Group{
|
||||
Id: &grouppb.GroupId{
|
||||
Idp: "idp",
|
||||
OpaqueId: "group-id",
|
||||
},
|
||||
DisplayName: "Group Name",
|
||||
},
|
||||
}, nil)
|
||||
gatewayClient.On("GetGroup",
|
||||
mock.Anything,
|
||||
mock.Anything).
|
||||
Return(&grouppb.GetGroupResponse{
|
||||
Status: status.NewNotFound(ctx, "mock group not found"),
|
||||
Group: nil,
|
||||
}, nil)
|
||||
|
||||
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
|
||||
identityBackend = &identitymocks.Backend{}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
ctx = context.Background()
|
||||
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
cfg.Commons = &shared.Commons{}
|
||||
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
|
||||
|
||||
svc, err = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
service.WithIdentityBackend(identityBackend),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
emptyListPublicSharesMock := func() {
|
||||
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(
|
||||
&link.ListPublicSharesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Share: []*link.PublicShare{},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
emptyListSharesMock := func() {
|
||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
||||
&collaboration.ListSharesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Shares: []*collaboration.Share{},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
Describe("GetSharedByMe", func() {
|
||||
It("handles a failing ListShares", func() {
|
||||
emptyListPublicSharesMock()
|
||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(nil, errors.New("some error"))
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||
svc.GetSharedByMe(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
|
||||
It("handles ListShares returning an error status", func() {
|
||||
emptyListPublicSharesMock()
|
||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
||||
&collaboration.ListSharesResponse{Status: status.NewInternal(ctx, "error listing shares")},
|
||||
nil,
|
||||
)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||
svc.GetSharedByMe(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
|
||||
It("succeeds, when no shares are returned", func() {
|
||||
emptyListPublicSharesMock()
|
||||
emptyListSharesMock()
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||
svc.GetSharedByMe(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := itemsList{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(res.Value)).To(Equal(0))
|
||||
})
|
||||
|
||||
It("returns a proper driveItem, when a single user share is returned", func() {
|
||||
emptyListPublicSharesMock()
|
||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
||||
&collaboration.ListSharesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Shares: []*collaboration.Share{
|
||||
&userShare,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||
svc.GetSharedByMe(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := itemsList{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
|
||||
di := res.Value[0]
|
||||
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(userShare.GetResourceId())))
|
||||
|
||||
perm := di.GetPermissions()
|
||||
Expect(perm[0].GetId()).To(Equal(userShare.GetId().GetOpaqueId()))
|
||||
_, ok := perm[0].GetExpirationDateTimeOk()
|
||||
Expect(ok).To(BeFalse())
|
||||
_, ok = perm[0].GrantedToV2.GetGroupOk()
|
||||
Expect(ok).To(BeFalse())
|
||||
user, ok := perm[0].GrantedToV2.GetUserOk()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(user.GetId()).To(Equal(userShare.GetGrantee().GetUserId().GetOpaqueId()))
|
||||
_, ok = perm[0].GetLinkOk()
|
||||
Expect(ok).To(BeFalse())
|
||||
roles, ok := perm[0].GetRolesOk()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(len(roles)).To(Equal(1))
|
||||
Expect(roles[0]).To(Equal(unifiedrole.UnifiedRoleEditorID))
|
||||
})
|
||||
|
||||
It("returns a proper driveItem, when a single group share is returned", func() {
|
||||
emptyListPublicSharesMock()
|
||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
||||
&collaboration.ListSharesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Shares: []*collaboration.Share{
|
||||
&groupShare,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||
svc.GetSharedByMe(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := itemsList{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
|
||||
di := res.Value[0]
|
||||
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(groupShare.GetResourceId())))
|
||||
|
||||
perm := di.GetPermissions()
|
||||
Expect(perm[0].GetId()).To(Equal(userShare.GetId().GetOpaqueId()))
|
||||
_, ok := perm[0].GetExpirationDateTimeOk()
|
||||
Expect(ok).To(BeFalse())
|
||||
_, ok = perm[0].GrantedToV2.GetUserOk()
|
||||
Expect(ok).To(BeFalse())
|
||||
group, ok := perm[0].GrantedToV2.GetGroupOk()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(group.GetId()).To(Equal(groupShare.GetGrantee().GetGroupId().GetOpaqueId()))
|
||||
_, ok = perm[0].GetLinkOk()
|
||||
Expect(ok).To(BeFalse())
|
||||
roles, ok := perm[0].GetRolesOk()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(len(roles)).To(Equal(1))
|
||||
Expect(roles[0]).To(Equal(unifiedrole.UnifiedRoleEditorID))
|
||||
})
|
||||
|
||||
It("returns a single driveItem, when a mulitple shares for the same resource are returned", func() {
|
||||
emptyListPublicSharesMock()
|
||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
||||
&collaboration.ListSharesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Shares: []*collaboration.Share{
|
||||
&groupShare,
|
||||
&userShare,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||
svc.GetSharedByMe(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := itemsList{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
|
||||
di := res.Value[0]
|
||||
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(groupShare.GetResourceId())))
|
||||
|
||||
// one permission per share
|
||||
Expect(len(di.GetPermissions())).To(Equal(2))
|
||||
})
|
||||
|
||||
It("return a driveItem with the expiration date set, for expiring shares", func() {
|
||||
emptyListPublicSharesMock()
|
||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
||||
&collaboration.ListSharesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Shares: []*collaboration.Share{
|
||||
&userShareWithExpiration,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||
svc.GetSharedByMe(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := itemsList{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
|
||||
di := res.Value[0]
|
||||
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(userShareWithExpiration.GetResourceId())))
|
||||
|
||||
perm := di.GetPermissions()
|
||||
Expect(perm[0].GetId()).To(Equal(userShareWithExpiration.GetId().GetOpaqueId()))
|
||||
exp, ok := perm[0].GetExpirationDateTimeOk()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(exp.Equal(expiration)).To(BeTrue())
|
||||
_, ok = perm[0].GrantedToV2.GetGroupOk()
|
||||
Expect(ok).To(BeFalse())
|
||||
user, ok := perm[0].GrantedToV2.GetUserOk()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(user.GetId()).To(Equal(userShareWithExpiration.GetGrantee().GetUserId().GetOpaqueId()))
|
||||
_, ok = perm[0].GetLinkOk()
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
// Public Shares / "links" in graph terms
|
||||
It("handles a failing ListPublicShares", func() {
|
||||
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(nil, errors.New("some error"))
|
||||
emptyListSharesMock()
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||
svc.GetSharedByMe(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
|
||||
It("handles ListPublicShares returning an error status", func() {
|
||||
emptyListSharesMock()
|
||||
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(
|
||||
&link.ListPublicSharesResponse{Status: status.NewInternal(ctx, "error listing shares")},
|
||||
nil,
|
||||
)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||
svc.GetSharedByMe(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
|
||||
It("returns a proper driveItem, when a single public share is returned", func() {
|
||||
emptyListSharesMock()
|
||||
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(
|
||||
&link.ListPublicSharesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Share: []*link.PublicShare{
|
||||
&publicShare,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||
svc.GetSharedByMe(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := itemsList{}
|
||||
err = json.Unmarshal(data, &res)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
|
||||
di := res.Value[0]
|
||||
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(publicShare.GetResourceId())))
|
||||
|
||||
perm := di.GetPermissions()
|
||||
Expect(perm[0].GetId()).To(Equal(publicShare.GetId().GetOpaqueId()))
|
||||
_, ok := perm[0].GetExpirationDateTimeOk()
|
||||
Expect(ok).To(BeFalse())
|
||||
_, ok = perm[0].GetGrantedToV2Ok()
|
||||
Expect(ok).To(BeFalse())
|
||||
link, ok := perm[0].GetLinkOk()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(link.GetWebUrl()).To(Equal("https://localhost:9200/s/" + publicShare.GetToken()))
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/share"
|
||||
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/thumbnail"
|
||||
)
|
||||
|
||||
// ListSharedWithMe lists the files shared with the current user.
|
||||
func (g Graph) ListSharedWithMe(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
expand := r.URL.Query().Get("$expand")
|
||||
expandThumbnails := strings.Contains(expand, "thumbnails")
|
||||
|
||||
driveItems, err := g.listSharedWithMe(ctx, expandThumbnails)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("listSharedWithMe failed")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &ListResponse{Value: driveItems})
|
||||
}
|
||||
|
||||
// listSharedWithMe is a helper function that lists the drive items shared with the current user.
|
||||
func (g Graph) listSharedWithMe(ctx context.Context, expandThumbnails bool) ([]libregraph.DriveItem, error) {
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not select next gateway client")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listReceivedSharesResponse, err := gatewayClient.ListReceivedShares(ctx, &collaboration.ListReceivedSharesRequest{
|
||||
Filters: []*collaboration.Filter{
|
||||
share.SpaceRootFilter(false),
|
||||
},
|
||||
})
|
||||
if err := errorcode.FromCS3Status(listReceivedSharesResponse.GetStatus(), err); err != nil {
|
||||
g.logger.Error().Err(err).Msg("listing shares failed")
|
||||
return nil, err
|
||||
}
|
||||
driveItems, err := cs3ReceivedSharesToDriveItems(ctx, g.logger, gatewayClient, g.identityCache, listReceivedSharesResponse.GetShares(), g.availableRoles)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not convert received shares to drive items")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if g.config.IncludeOCMSharees {
|
||||
listReceivedOCMSharesResponse, err := gatewayClient.ListReceivedOCMShares(ctx, &ocm.ListReceivedOCMSharesRequest{})
|
||||
if err := errorcode.FromCS3Status(listReceivedSharesResponse.GetStatus(), err); err != nil {
|
||||
g.logger.Error().Err(err).Msg("listing shares failed")
|
||||
return nil, err
|
||||
}
|
||||
ocmDriveItems, err := cs3ReceivedOCMSharesToDriveItems(ctx, g.logger, gatewayClient, g.identityCache, listReceivedOCMSharesResponse.GetShares(), g.availableRoles)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("could not convert received ocm shares to drive items")
|
||||
return nil, err
|
||||
}
|
||||
driveItems = append(driveItems, ocmDriveItems...)
|
||||
}
|
||||
|
||||
if expandThumbnails {
|
||||
for k, item := range driveItems {
|
||||
mt := item.GetFile().MimeType
|
||||
if mt == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_, match := thumbnail.SupportedMimeTypes[*mt]
|
||||
if match {
|
||||
baseUrl := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail",
|
||||
g.config.Commons.QsferaURL,
|
||||
item.RemoteItem.GetId())
|
||||
smallUrl := baseUrl + "&x=36&y=36"
|
||||
mediumUrl := baseUrl + "&x=48&y=48"
|
||||
largeUrl := baseUrl + "&x=96&y=96"
|
||||
|
||||
item.SetThumbnails([]libregraph.ThumbnailSet{
|
||||
{
|
||||
Small: &libregraph.Thumbnail{Url: &smallUrl},
|
||||
Medium: &libregraph.Thumbnail{Url: &mediumUrl},
|
||||
Large: &libregraph.Thumbnail{Url: &largeUrl},
|
||||
},
|
||||
})
|
||||
|
||||
driveItems[k] = item // assign modified item back to the map
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return driveItems, err
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
groupv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
collaborationv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
roleconversions "github.com/opencloud-eu/reva/v2/pkg/conversions"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/services/graph/pkg/config"
|
||||
"github.com/qsfera/server/services/graph/pkg/config/defaults"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
|
||||
service "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
// "github.com/qsfera/server/services/graph/pkg/unifiedrole"
|
||||
)
|
||||
|
||||
var _ = Describe("SharedWithMe", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
cfg *config.Config
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
identityBackend *identitymocks.Backend
|
||||
ctx context.Context
|
||||
tape *httptest.ResponseRecorder
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
|
||||
identityBackend = &identitymocks.Backend{}
|
||||
|
||||
tape = httptest.NewRecorder()
|
||||
ctx = context.Background()
|
||||
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
cfg.Commons = &shared.Commons{}
|
||||
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
|
||||
|
||||
var err error
|
||||
svc, err = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.WithIdentityBackend(identityBackend),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("ListSharedWithMe", func() {
|
||||
var (
|
||||
listReceivedSharesResponse *collaborationv1beta1.ListReceivedSharesResponse
|
||||
statResponse *providerv1beta1.StatResponse
|
||||
getUserResponseDefault *userv1beta1.GetUserResponse
|
||||
getUserResponseResourceCreator *userv1beta1.GetUserResponse
|
||||
getUserResponseShareCreator *userv1beta1.GetUserResponse
|
||||
)
|
||||
|
||||
toResourceID := func(in string) *providerv1beta1.ResourceId {
|
||||
out, err := storagespace.ParseID(in)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
return &out
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
|
||||
getUserResponseDefault = &userv1beta1.GetUserResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
User: &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "2699b42d-c6ca-4ce1-90de-89dedfb3022c",
|
||||
},
|
||||
DisplayName: "John Romero",
|
||||
},
|
||||
}
|
||||
getUserResponseResourceCreator = &userv1beta1.GetUserResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
User: &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "resource-creator-id",
|
||||
},
|
||||
DisplayName: "Resource Creator",
|
||||
},
|
||||
}
|
||||
getUserResponseShareCreator = &userv1beta1.GetUserResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
User: &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "share-creator-id",
|
||||
},
|
||||
DisplayName: "Share Creator",
|
||||
},
|
||||
}
|
||||
|
||||
gatewayClient.On("GetUser", mock.Anything, mock.MatchedBy(
|
||||
func(req *userv1beta1.GetUserRequest) bool {
|
||||
return req.UserId.OpaqueId == "resource-creator-id"
|
||||
})).
|
||||
Return(getUserResponseResourceCreator, nil)
|
||||
gatewayClient.On("GetUser", mock.Anything, mock.MatchedBy(
|
||||
func(req *userv1beta1.GetUserRequest) bool {
|
||||
return req.UserId.OpaqueId == "share-creator-id"
|
||||
})).
|
||||
Return(getUserResponseShareCreator, nil)
|
||||
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponseDefault, nil)
|
||||
|
||||
gatewayClient.On("GetGroup", mock.Anything, mock.Anything).
|
||||
Return(
|
||||
&groupv1beta1.GetGroupResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Group: &groupv1beta1.Group{
|
||||
Id: &groupv1beta1.GroupId{
|
||||
OpaqueId: "group-id",
|
||||
},
|
||||
DisplayName: "Group",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
listReceivedSharesResponse = &collaborationv1beta1.ListReceivedSharesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Shares: []*collaborationv1beta1.ReceivedShare{
|
||||
{
|
||||
Share: &collaborationv1beta1.Share{
|
||||
ResourceId: toResourceID("1$2!3"),
|
||||
Id: &collaborationv1beta1.ShareId{
|
||||
OpaqueId: "sh:are:id",
|
||||
},
|
||||
Permissions: &collaborationv1beta1.SharePermissions{
|
||||
Permissions: roleconversions.NewViewerRole().CS3ResourcePermissions(),
|
||||
},
|
||||
Creator: getUserResponseShareCreator.User.Id,
|
||||
Ctime: utils.TSNow(),
|
||||
},
|
||||
MountPoint: &providerv1beta1.Reference{
|
||||
ResourceId: &providerv1beta1.ResourceId{
|
||||
StorageId: utils.ShareStorageProviderID,
|
||||
SpaceId: utils.ShareStorageSpaceID,
|
||||
},
|
||||
Path: "some folder",
|
||||
},
|
||||
State: collaborationv1beta1.ShareState_SHARE_STATE_ACCEPTED,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
gatewayClient.On("ListReceivedShares", mock.Anything, mock.Anything).Return(listReceivedSharesResponse, nil)
|
||||
|
||||
statResponse = &providerv1beta1.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &providerv1beta1.ResourceInfo{
|
||||
Type: providerv1beta1.ResourceType_RESOURCE_TYPE_CONTAINER,
|
||||
},
|
||||
}
|
||||
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(func(_ context.Context, r *providerv1beta1.StatRequest, _ ...grpc.CallOption) (*providerv1beta1.StatResponse, error) {
|
||||
// copy the response to avoid side effects
|
||||
res := proto.Clone(statResponse).(*providerv1beta1.StatResponse)
|
||||
for _, share := range listReceivedSharesResponse.Shares {
|
||||
if share.Share.ResourceId != r.Ref.ResourceId {
|
||||
continue
|
||||
}
|
||||
|
||||
res.Info.Id = share.Share.ResourceId
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
})
|
||||
})
|
||||
|
||||
It("fails if no received shares were found", func() {
|
||||
listReceivedSharesResponse.Status = status.NewNotFound(ctx, "msg")
|
||||
|
||||
svc.ListSharedWithMe(
|
||||
tape,
|
||||
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
|
||||
)
|
||||
|
||||
Expect(tape.Code, errorcode.ItemNotFound)
|
||||
})
|
||||
|
||||
It("includes hidden shares", func() {
|
||||
listReceivedSharesResponse.Shares = append(listReceivedSharesResponse.Shares, &collaborationv1beta1.ReceivedShare{
|
||||
Hidden: true,
|
||||
Share: &collaborationv1beta1.Share{
|
||||
ResourceId: toResourceID("7$8!9"),
|
||||
Ctime: utils.TSNow(),
|
||||
},
|
||||
})
|
||||
|
||||
svc.ListSharedWithMe(
|
||||
tape,
|
||||
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
|
||||
)
|
||||
|
||||
jsonData := gjson.Get(tape.Body.String(), "value")
|
||||
|
||||
Expect(len(listReceivedSharesResponse.Shares)).To(Equal(2))
|
||||
Expect(jsonData.Get("#").Num).To(Equal(float64(2)))
|
||||
})
|
||||
|
||||
It("populates the driveItem properties", func() {
|
||||
|
||||
share := listReceivedSharesResponse.Shares[0].Share
|
||||
share.Id = &collaborationv1beta1.ShareId{OpaqueId: "1:2:3"}
|
||||
share.Ctime = &typesv1beta1.Timestamp{Seconds: 4001}
|
||||
share.Mtime = &typesv1beta1.Timestamp{Seconds: 4002}
|
||||
|
||||
resourceInfo := statResponse.Info
|
||||
resourceInfo.Name = "some folder"
|
||||
resourceInfo.Etag = "\"5ffb8e4bec7026050af7fde9482b289a\""
|
||||
resourceInfo.Owner = &userv1beta1.UserId{
|
||||
OpaqueId: "resource-creator-id",
|
||||
}
|
||||
resourceInfo.Mtime = &typesv1beta1.Timestamp{Seconds: 40000}
|
||||
|
||||
svc.ListSharedWithMe(
|
||||
tape,
|
||||
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
|
||||
)
|
||||
|
||||
jsonData := gjson.Get(tape.Body.String(), "value.0")
|
||||
|
||||
Expect(jsonData.Get("eTag").String()).To(Equal(resourceInfo.Etag))
|
||||
Expect(jsonData.Get("id").String()).To(Equal(storagespace.FormatResourceID(
|
||||
&providerv1beta1.ResourceId{
|
||||
StorageId: utils.ShareStorageProviderID,
|
||||
SpaceId: utils.ShareStorageSpaceID,
|
||||
OpaqueId: share.Id.OpaqueId,
|
||||
},
|
||||
)))
|
||||
Expect(jsonData.Get("lastModifiedDateTime").String()).To(Equal(utils.TSToTime(resourceInfo.Mtime).Format(time.RFC3339Nano)))
|
||||
Expect(jsonData.Get("createdBy.user.id").String()).To(Equal(resourceInfo.Owner.OpaqueId))
|
||||
Expect(jsonData.Get("name").String()).To(Equal(resourceInfo.Name))
|
||||
|
||||
})
|
||||
|
||||
It("populates the driveItem parentReference properties", func() {
|
||||
share := listReceivedSharesResponse.Shares[0].Share
|
||||
share.Id = &collaborationv1beta1.ShareId{OpaqueId: "1:2:3"}
|
||||
|
||||
svc.ListSharedWithMe(
|
||||
tape,
|
||||
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
|
||||
)
|
||||
|
||||
jsonData := gjson.Get(tape.Body.String(), "value.0.parentReference")
|
||||
|
||||
Expect(jsonData.Get("driveId").String()).To(Equal(storagespace.FormatStorageID(utils.ShareStorageProviderID, utils.ShareStorageSpaceID)))
|
||||
})
|
||||
|
||||
It("populates the driveItem remoteItem properties", func() {
|
||||
share := listReceivedSharesResponse.Shares[0].Share
|
||||
share.Id = &collaborationv1beta1.ShareId{OpaqueId: "1:2:3"}
|
||||
share.Ctime = &typesv1beta1.Timestamp{Seconds: 4001}
|
||||
share.Mtime = &typesv1beta1.Timestamp{Seconds: 40002}
|
||||
|
||||
resourceInfo := statResponse.Info
|
||||
resourceInfo.Name = "some folder"
|
||||
resourceInfo.Mtime = &typesv1beta1.Timestamp{Seconds: 40000}
|
||||
resourceInfo.Size = 500
|
||||
resourceInfo.Etag = "\"5ffb8e4bec7026050af7fde9482b289a\""
|
||||
|
||||
svc.ListSharedWithMe(
|
||||
tape,
|
||||
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
|
||||
)
|
||||
|
||||
jsonData := gjson.Get(tape.Body.String(), "value.0.remoteItem")
|
||||
|
||||
Expect(jsonData.Get("eTag").String()).To(Equal(resourceInfo.Etag))
|
||||
Expect(jsonData.Get("id").String()).To(Equal(storagespace.FormatResourceID(share.ResourceId)))
|
||||
Expect(jsonData.Get("lastModifiedDateTime").String()).To(Equal(utils.TSToTime(resourceInfo.Mtime).Format(time.RFC3339Nano)))
|
||||
Expect(jsonData.Get("name").String()).To(Equal(resourceInfo.Name))
|
||||
Expect(jsonData.Get("size").Num).To(Equal(float64(resourceInfo.Size)))
|
||||
})
|
||||
|
||||
It("populates the driveItem.remoteItem.createdBy properties", func() {
|
||||
driveOwner := getUserResponseDefault.User
|
||||
|
||||
resourceInfo := statResponse.Info
|
||||
resourceInfo.Owner = driveOwner.Id
|
||||
|
||||
svc.ListSharedWithMe(
|
||||
tape,
|
||||
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
|
||||
)
|
||||
|
||||
jsonData := gjson.Get(tape.Body.String(), "value.0.remoteItem.createdBy")
|
||||
|
||||
Expect(jsonData.Get("user.displayName").String()).To(Equal(driveOwner.DisplayName))
|
||||
Expect(jsonData.Get("user.id").String()).To(Equal(driveOwner.Id.OpaqueId))
|
||||
})
|
||||
|
||||
It("populates the driveItem.remoteItem.folder properties", func() {
|
||||
|
||||
svc.ListSharedWithMe(
|
||||
tape,
|
||||
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
|
||||
)
|
||||
|
||||
jsonData := gjson.Get(tape.Body.String(), "value.0.remoteItem")
|
||||
|
||||
Expect(jsonData.Get("file").Exists()).To(BeFalse())
|
||||
Expect(jsonData.Get("folder.childCount").Num).To(Equal(float64(0)))
|
||||
})
|
||||
|
||||
It("populates the driveItem.remoteItem.file properties", func() {
|
||||
resourceInfo := statResponse.Info
|
||||
resourceInfo.Type = providerv1beta1.ResourceType_RESOURCE_TYPE_FILE
|
||||
resourceInfo.MimeType = "application/pdf"
|
||||
|
||||
svc.ListSharedWithMe(
|
||||
tape,
|
||||
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
|
||||
)
|
||||
|
||||
jsonData := gjson.Get(tape.Body.String(), "value.0.remoteItem")
|
||||
|
||||
Expect(jsonData.Get("folder").Exists()).To(BeFalse())
|
||||
Expect(jsonData.Get("file.mimeType").String()).To(Equal(resourceInfo.MimeType))
|
||||
})
|
||||
|
||||
It("returns shares created on project space", func() {
|
||||
ownerID := &userv1beta1.UserId{
|
||||
OpaqueId: "project-space-id",
|
||||
Type: userv1beta1.UserType_USER_TYPE_SPACE_OWNER,
|
||||
}
|
||||
share := listReceivedSharesResponse.Shares[0].Share
|
||||
share.Owner = ownerID
|
||||
resourceInfo := statResponse.Info
|
||||
resourceInfo.Owner = ownerID
|
||||
|
||||
svc.ListSharedWithMe(
|
||||
tape,
|
||||
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
|
||||
)
|
||||
|
||||
jsonData := gjson.Get(tape.Body.String(), "value.0.createdBy")
|
||||
|
||||
Expect(jsonData.String()).To(Equal(""))
|
||||
|
||||
jsonData = gjson.Get(tape.Body.String(), "value.0.remoteItem.permissions.0.invitation.invitedBy.user")
|
||||
Expect(jsonData.Get("displayName").String()).To(Equal(getUserResponseShareCreator.User.DisplayName))
|
||||
Expect(jsonData.Get("id").String()).To(Equal(getUserResponseShareCreator.User.Id.OpaqueId))
|
||||
})
|
||||
|
||||
It("returns a single drive item when multiple shares exist for the same resource", func() {
|
||||
anotherShare := &collaborationv1beta1.ReceivedShare{
|
||||
Share: &collaborationv1beta1.Share{
|
||||
ResourceId: toResourceID("1$2!3"),
|
||||
Id: &collaborationv1beta1.ShareId{
|
||||
OpaqueId: "sh:are:id2",
|
||||
},
|
||||
Permissions: &collaborationv1beta1.SharePermissions{
|
||||
Permissions: roleconversions.NewViewerRole().CS3ResourcePermissions(),
|
||||
},
|
||||
Creator: getUserResponseShareCreator.User.Id,
|
||||
Ctime: utils.TSNow(),
|
||||
Grantee: &providerv1beta1.Grantee{
|
||||
Type: providerv1beta1.GranteeType_GRANTEE_TYPE_GROUP,
|
||||
Id: &providerv1beta1.Grantee_GroupId{
|
||||
GroupId: &groupv1beta1.GroupId{
|
||||
OpaqueId: "group-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
listReceivedSharesResponse.Shares = append(listReceivedSharesResponse.Shares, anotherShare)
|
||||
svc.ListSharedWithMe(
|
||||
tape,
|
||||
httptest.NewRequest(http.MethodGet, "/graph/v1beta1/me/drive/sharedWithMe", nil),
|
||||
)
|
||||
driveItems := libregraph.CollectionOfDriveItems{}
|
||||
err := json.Unmarshal(tape.Body.Bytes(), &driveItems)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(len(driveItems.Value)).To(Equal(1))
|
||||
ri := driveItems.GetValue()[0].GetRemoteItem()
|
||||
Expect(len(ri.GetPermissions())).To(Equal(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,124 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
v1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
"github.com/qsfera/server/pkg/l10n"
|
||||
l10n_pkg "github.com/qsfera/server/services/graph/pkg/l10n"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/metadata"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed spacetemplate/*
|
||||
_spaceTemplateFS embed.FS
|
||||
|
||||
// name of the secret space folder
|
||||
_spaceFolderName = ".space"
|
||||
|
||||
// path to the image file
|
||||
_imagepath = "spacetemplate/image.png"
|
||||
|
||||
// default description for new spaces
|
||||
_readmeText = l10n.Template("Here you can add a description for this Space.")
|
||||
|
||||
// name of the readme.md file
|
||||
_readmeName = "readme.md"
|
||||
|
||||
// HeaderAcceptLanguage is the header key for the accept-language header
|
||||
HeaderAcceptLanguage = "Accept-Language"
|
||||
|
||||
// TemplateParameter is the key for the template parameter in the request
|
||||
TemplateParameter = "template"
|
||||
)
|
||||
|
||||
func (g Graph) applySpaceTemplate(ctx context.Context, gwc gateway.GatewayAPIClient, root *storageprovider.ResourceId, template string, locale string) error {
|
||||
switch template {
|
||||
default:
|
||||
fallthrough
|
||||
case "none":
|
||||
return nil
|
||||
case "default":
|
||||
return g.applyDefaultTemplate(ctx, gwc, root, locale)
|
||||
}
|
||||
}
|
||||
|
||||
func (g Graph) applyDefaultTemplate(ctx context.Context, gwc gateway.GatewayAPIClient, root *storageprovider.ResourceId, locale string) error {
|
||||
mdc := metadata.NewCS3(g.config.Reva.Address, g.config.Spaces.StorageUsersAddress)
|
||||
mdc.SpaceRoot = root
|
||||
|
||||
var opaque *v1beta1.Opaque
|
||||
|
||||
// create .space folder
|
||||
if err := mdc.MakeDirIfNotExist(ctx, _spaceFolderName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// upload space image
|
||||
iid, err := imageUpload(ctx, mdc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opaque = utils.AppendPlainToOpaque(opaque, SpaceImageSpecialFolderName, iid)
|
||||
|
||||
// upload readme.md
|
||||
rid, err := readmeUpload(ctx, mdc, locale, g.config.Spaces.DefaultLanguage, g.config.Spaces.TranslationPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opaque = utils.AppendPlainToOpaque(opaque, ReadmeSpecialFolderName, rid)
|
||||
|
||||
// update space
|
||||
resp, err := gwc.UpdateStorageSpace(ctx, &storageprovider.UpdateStorageSpaceRequest{
|
||||
StorageSpace: &storageprovider.StorageSpace{
|
||||
Id: &storageprovider.StorageSpaceId{
|
||||
OpaqueId: storagespace.FormatResourceID(root),
|
||||
},
|
||||
Root: root,
|
||||
Opaque: opaque,
|
||||
},
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
return err
|
||||
case resp.GetStatus().GetCode() != rpc.Code_CODE_OK:
|
||||
return fmt.Errorf("could not update storage space: %s", resp.GetStatus().GetMessage())
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func imageUpload(ctx context.Context, mdc *metadata.CS3) (string, error) {
|
||||
b, err := fs.ReadFile(_spaceTemplateFS, _imagepath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := mdc.Upload(ctx, metadata.UploadRequest{
|
||||
Path: filepath.Join(_spaceFolderName, filepath.Base(_imagepath)),
|
||||
Content: b,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return res.FileID, nil
|
||||
}
|
||||
|
||||
func readmeUpload(ctx context.Context, mdc *metadata.CS3, locale string, defaultLocale string, translationPath string) (string, error) {
|
||||
res, err := mdc.Upload(ctx, metadata.UploadRequest{
|
||||
Path: filepath.Join(_spaceFolderName, _readmeName),
|
||||
Content: []byte(l10n_pkg.Translate(_readmeText, locale, defaultLocale, translationPath)),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return res.FileID, nil
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/render"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
revaCtx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/tags"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"go-micro.dev/v4/metadata"
|
||||
)
|
||||
|
||||
// GetTags returns all available tags
|
||||
func (g Graph) GetTags(w http.ResponseWriter, r *http.Request) {
|
||||
th := r.Header.Get(revaCtx.TokenHeader)
|
||||
ctx := revaCtx.ContextSetToken(r.Context(), th)
|
||||
ctx = metadata.Set(ctx, revaCtx.TokenHeader, th)
|
||||
sr, err := g.searchService.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: "Tags:*",
|
||||
PageSize: -1,
|
||||
})
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("Could not search for tags")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tagList := tags.New("")
|
||||
for _, match := range sr.Matches {
|
||||
for _, tag := range match.Entity.Tags {
|
||||
tagList.Add(tag)
|
||||
}
|
||||
}
|
||||
|
||||
tagCollection := libregraph.NewCollectionOfTags()
|
||||
tagCollection.Value = tagList.AsSlice()
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, tagCollection)
|
||||
}
|
||||
|
||||
// AssignTags assigns a tag to a resource
|
||||
func (g Graph) AssignTags(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
assignment libregraph.TagAssignment
|
||||
ctx = r.Context()
|
||||
)
|
||||
|
||||
if err := StrictJSONUnmarshal(r.Body, &assignment); err != nil {
|
||||
g.logger.Debug().Err(err).Interface("body", r.Body).Msg("could not decode tag assignment request")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition")
|
||||
return
|
||||
}
|
||||
|
||||
rid, err := storagespace.ParseID(assignment.ResourceId)
|
||||
if err != nil {
|
||||
g.logger.Debug().Err(err).Msg("could not parse resourceId")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid resourceId")
|
||||
return
|
||||
}
|
||||
|
||||
client, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error selecting next gateway client")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sres, err := client.Stat(ctx, &provider.StatRequest{
|
||||
Ref: &provider.Reference{ResourceId: &rid},
|
||||
})
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error stating file")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if sres.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "can't stat resource")
|
||||
return
|
||||
}
|
||||
|
||||
// use resource id from stat response to work on the actual resource and not a share jail item
|
||||
rid = *sres.GetInfo().GetId()
|
||||
|
||||
pm := sres.GetInfo().GetPermissionSet()
|
||||
if pm == nil {
|
||||
g.logger.Error().Err(err).Msg("no permissionset on file")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// it says we need "write access" to set tags. One of those should do
|
||||
if !pm.InitiateFileUpload && !pm.CreateContainer {
|
||||
g.logger.Info().Msg("no permission to create a tag")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var currentTags string
|
||||
if m := sres.GetInfo().GetArbitraryMetadata().GetMetadata(); m != nil {
|
||||
currentTags = m["tags"]
|
||||
}
|
||||
|
||||
allTags := tags.New(currentTags)
|
||||
if !allTags.Add(assignment.Tags...) {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "no new tags in createtagsrequest or maximum reached")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.SetArbitraryMetadata(ctx, &provider.SetArbitraryMetadataRequest{
|
||||
Ref: &provider.Reference{ResourceId: &rid},
|
||||
ArbitraryMetadata: &provider.ArbitraryMetadata{
|
||||
Metadata: map[string]string{
|
||||
"tags": allTags.AsList(),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil || resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
g.logger.Error().Err(err).Interface("status", resp.GetStatus()).Msg("error setting tags")
|
||||
if resp.GetStatus().GetCode() == rpc.Code_CODE_LOCKED {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusLocked, "file is locked")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if g.eventsPublisher != nil {
|
||||
ev := events.TagsAdded{
|
||||
Tags: strings.Join(assignment.Tags, ","),
|
||||
Ref: &provider.Reference{
|
||||
ResourceId: &rid,
|
||||
Path: ".",
|
||||
},
|
||||
SpaceOwner: sres.Info.Owner,
|
||||
Executant: revaCtx.ContextMustGetUser(r.Context()).Id,
|
||||
}
|
||||
if err := events.Publish(r.Context(), g.eventsPublisher, ev); err != nil {
|
||||
g.logger.Error().Err(err).Msg("Failed to publish TagsAdded event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UnassignTags removes a tag from a resource
|
||||
func (g Graph) UnassignTags(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
unassignment libregraph.TagUnassignment
|
||||
ctx = r.Context()
|
||||
)
|
||||
|
||||
if err := StrictJSONUnmarshal(r.Body, &unassignment); err != nil {
|
||||
g.logger.Debug().Err(err).Interface("body", r.Body).Msg("could not decode tag assignment request")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition")
|
||||
return
|
||||
}
|
||||
|
||||
rid, err := storagespace.ParseID(unassignment.ResourceId)
|
||||
if err != nil {
|
||||
g.logger.Debug().Err(err).Msg("could not parse resourceId")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid resourceId")
|
||||
return
|
||||
}
|
||||
|
||||
client, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error selecting next gateway client")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sres, err := client.Stat(ctx, &provider.StatRequest{
|
||||
Ref: &provider.Reference{ResourceId: &rid},
|
||||
})
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error stating file")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if sres.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "can't stat resource")
|
||||
return
|
||||
}
|
||||
|
||||
// use resource id from stat response to work on the actual resource and not a share jail item
|
||||
rid = *sres.GetInfo().GetId()
|
||||
|
||||
pm := sres.GetInfo().GetPermissionSet()
|
||||
if pm == nil {
|
||||
g.logger.Error().Err(err).Msg("no permissionset on file")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// it says we need "write access" to set tags. One of those should do
|
||||
if !pm.InitiateFileUpload && !pm.CreateContainer {
|
||||
g.logger.Info().Msg("no permission to create a tag")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var currentTags string
|
||||
if m := sres.GetInfo().GetArbitraryMetadata().GetMetadata(); m != nil {
|
||||
currentTags = m["tags"]
|
||||
}
|
||||
|
||||
allTags := tags.New(currentTags)
|
||||
if !allTags.Remove(unassignment.Tags...) {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "no new tags in createtagsrequest or maximum reached")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.SetArbitraryMetadata(ctx, &provider.SetArbitraryMetadataRequest{
|
||||
Ref: &provider.Reference{ResourceId: &rid},
|
||||
ArbitraryMetadata: &provider.ArbitraryMetadata{
|
||||
Metadata: map[string]string{
|
||||
"tags": allTags.AsList(),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil || resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
g.logger.Error().Err(err).Msg("error setting tags")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if g.eventsPublisher != nil {
|
||||
ev := events.TagsRemoved{
|
||||
Tags: strings.Join(unassignment.Tags, ","),
|
||||
Ref: &provider.Reference{
|
||||
ResourceId: &rid,
|
||||
Path: ".",
|
||||
},
|
||||
SpaceOwner: sres.Info.Owner,
|
||||
Executant: revaCtx.ContextMustGetUser(r.Context()).Id,
|
||||
}
|
||||
if err := events.Publish(ctx, g.eventsPublisher, ev); err != nil {
|
||||
g.logger.Error().Err(err).Msg("Failed to publish TagsAdded event")
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
|
||||
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
|
||||
)
|
||||
|
||||
const (
|
||||
appRoleID = "appRoleId"
|
||||
appRoleAssignments = "appRoleAssignments"
|
||||
unsupportedFilter = "unsupported filter"
|
||||
)
|
||||
|
||||
func invalidFilterError() error {
|
||||
return godata.BadRequestError("invalid filter")
|
||||
}
|
||||
|
||||
func unsupportedFilterError() error {
|
||||
return godata.NotImplementedError(unsupportedFilter)
|
||||
}
|
||||
|
||||
func (g Graph) applyUserFilter(ctx context.Context, req *godata.GoDataRequest, root *godata.ParseNode) (users []*libregraph.User, err error) {
|
||||
logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
|
||||
if root == nil {
|
||||
root = req.Query.Filter.Tree
|
||||
}
|
||||
|
||||
switch root.Token.Type {
|
||||
case godata.ExpressionTokenLambdaNav:
|
||||
return g.applyFilterLambda(ctx, req, root.Children)
|
||||
case godata.ExpressionTokenLogical:
|
||||
return g.applyFilterLogical(ctx, req, root)
|
||||
case godata.ExpressionTokenFunc:
|
||||
return g.applyFilterFunction(ctx, req, root)
|
||||
}
|
||||
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
|
||||
func (g Graph) applyFilterFunction(ctx context.Context, req *godata.GoDataRequest, root *godata.ParseNode) (users []*libregraph.User, err error) {
|
||||
logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
if root.Token.Type != godata.ExpressionTokenFunc {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
|
||||
switch root.Token.Value {
|
||||
case "startswith":
|
||||
// 'startswith' needs 2 operands
|
||||
if len(root.Children) != 2 {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
return g.applyFilterFunctionStartsWith(ctx, req, root.Children[0], root.Children[1]), nil
|
||||
case "contains":
|
||||
// 'contains' needs 2 operands
|
||||
if len(root.Children) != 2 {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
return g.applyFilterFunctionContains(ctx, req, root.Children[0], root.Children[1]), nil
|
||||
}
|
||||
logger.Debug().Str("Token", root.Token.Value).Msg("unsupported function filter")
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
|
||||
func (g Graph) applyFilterFunctionStartsWith(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User) {
|
||||
logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
if operand1.Token.Type != godata.ExpressionTokenLiteral {
|
||||
logger.Debug().Str("Token", operand1.Token.Value).Msg("unsupported function filter")
|
||||
return users
|
||||
}
|
||||
|
||||
switch operand1.Token.Value {
|
||||
case "displayName":
|
||||
var retUsers []*libregraph.User
|
||||
filterValue := strings.Trim(operand2.Token.Value, "'")
|
||||
logger.Debug().Str("property", operand2.Token.Value).Str("value", filterValue).Msg("Filtering displayName by startsWith")
|
||||
if users, err := g.identityBackend.GetUsers(ctx, req); err == nil {
|
||||
for _, user := range users {
|
||||
if strings.HasPrefix(strings.ToLower(user.GetDisplayName()), strings.ToLower(filterValue)) {
|
||||
retUsers = append(retUsers, user)
|
||||
}
|
||||
}
|
||||
}
|
||||
return retUsers
|
||||
default:
|
||||
logger.Warn().Str("Token", operand1.Token.Value).Msg("unsupported function filter")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (g Graph) applyFilterFunctionContains(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User) {
|
||||
logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
if operand1.Token.Type != godata.ExpressionTokenLiteral {
|
||||
logger.Debug().Str("Token", operand1.Token.Value).Msg("unsupported function filter")
|
||||
return users
|
||||
}
|
||||
|
||||
switch operand1.Token.Value {
|
||||
case "displayName":
|
||||
var retUsers []*libregraph.User
|
||||
filterValue := strings.Trim(operand2.Token.Value, "'")
|
||||
logger.Debug().Str("property", operand2.Token.Value).Str("value", filterValue).Msg("Filtering displayName by contains")
|
||||
if users, err := g.identityBackend.GetUsers(ctx, req); err == nil {
|
||||
for _, user := range users {
|
||||
if strings.Contains(strings.ToLower(user.GetDisplayName()), strings.ToLower(filterValue)) {
|
||||
retUsers = append(retUsers, user)
|
||||
}
|
||||
}
|
||||
}
|
||||
return retUsers
|
||||
default:
|
||||
logger.Warn().Str("Token", operand1.Token.Value).Msg("unsupported function filter")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (g Graph) applyFilterLogical(ctx context.Context, req *godata.GoDataRequest, root *godata.ParseNode) (users []*libregraph.User, err error) {
|
||||
logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
if root.Token.Type != godata.ExpressionTokenLogical {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
|
||||
// As we currently don't suppport the 'has' or 'in' operator, all our
|
||||
// currently supported user filters of the ExpressionTokenLogical type
|
||||
// require exactly two operands.
|
||||
if len(root.Children) != 2 {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
switch root.Token.Value {
|
||||
case "and":
|
||||
return g.applyFilterLogicalAnd(ctx, req, root.Children[0], root.Children[1])
|
||||
case "or":
|
||||
return g.applyFilterLogicalOr(ctx, req, root.Children[0], root.Children[1])
|
||||
case "eq":
|
||||
return g.applyFilterEq(ctx, req, root.Children[0], root.Children[1])
|
||||
case "le":
|
||||
return g.applyFilterLessOrEqual(ctx, req, root)
|
||||
}
|
||||
logger.Debug().Str("Token", root.Token.Value).Msg("unsupported logical filter")
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
|
||||
func (g Graph) applyFilterLogicalAnd(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User, err error) {
|
||||
logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
var res1, res2 []*libregraph.User
|
||||
|
||||
// The appRoleAssignmentFilter requires a full list of user, get try to avoid a full user listing and
|
||||
// process the other part of the filter first, if that is an appRoleAssignmentFilter as well, we have
|
||||
// to bite the bullet and get a full user listing anyway
|
||||
if matched, property, value := g.isAppRoleAssignmentFilter(ctx, operand1); matched {
|
||||
logger.Debug().Str("property", property).Str("value", value).Msg("delay processing of approleAssignments filter")
|
||||
if property != appRoleID {
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
res2, err = g.applyUserFilter(ctx, req, operand2)
|
||||
if err != nil {
|
||||
return []*libregraph.User{}, err
|
||||
}
|
||||
logger.Debug().Str("property", property).Str("value", value).Msg("applying approleAssignments filter on result set")
|
||||
return g.filterUsersByAppRoleID(ctx, req, value, res2)
|
||||
}
|
||||
|
||||
// 1st part is no appRoleAssignmentFilter, run the filter query
|
||||
res1, err = g.applyUserFilter(ctx, req, operand1)
|
||||
if err != nil {
|
||||
return []*libregraph.User{}, err
|
||||
}
|
||||
|
||||
// Now check 2nd part for appRoleAssignmentFilter and apply that using the result return from the first
|
||||
// filter
|
||||
if matched, property, value := g.isAppRoleAssignmentFilter(ctx, operand2); matched {
|
||||
if property != appRoleID {
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
logger.Debug().Str("property", property).Str("value", value).Msg("applying approleAssignments filter on result set")
|
||||
return g.filterUsersByAppRoleID(ctx, req, value, res1)
|
||||
}
|
||||
|
||||
// 2nd part is no appRoleAssignmentFilter either
|
||||
res2, err = g.applyUserFilter(ctx, req, operand2)
|
||||
if err != nil {
|
||||
return []*libregraph.User{}, err
|
||||
}
|
||||
|
||||
// We now have two slice with results of the subfilters. Now turn one of them
|
||||
// into a map for efficiently getting the intersection of both slices
|
||||
userSet := userSliceToMap(res1)
|
||||
var filteredUsers []*libregraph.User
|
||||
for _, user := range res2 {
|
||||
if _, found := userSet[user.GetId()]; found {
|
||||
filteredUsers = append(filteredUsers, user)
|
||||
}
|
||||
}
|
||||
return filteredUsers, nil
|
||||
}
|
||||
|
||||
func (g Graph) applyFilterLogicalOr(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User, err error) {
|
||||
// logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
var res1, res2 []*libregraph.User
|
||||
|
||||
res1, err = g.applyUserFilter(ctx, req, operand1)
|
||||
if err != nil {
|
||||
return []*libregraph.User{}, err
|
||||
}
|
||||
|
||||
res2, err = g.applyUserFilter(ctx, req, operand2)
|
||||
if err != nil {
|
||||
return []*libregraph.User{}, err
|
||||
}
|
||||
|
||||
// We now have two slices with results of the subfilters. Now turn one of them
|
||||
// into a map for efficiently getting the union of both slices
|
||||
userSet := userSliceToMap(res1)
|
||||
filteredUsers := make([]*libregraph.User, 0, len(res1)+len(res2))
|
||||
filteredUsers = append(filteredUsers, res1...)
|
||||
for _, user := range res2 {
|
||||
if _, found := userSet[user.GetId()]; !found {
|
||||
filteredUsers = append(filteredUsers, user)
|
||||
}
|
||||
}
|
||||
return filteredUsers, nil
|
||||
}
|
||||
|
||||
func (g Graph) applyFilterEq(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User, err error) {
|
||||
// We only support the 'eq' on 'userType' for now
|
||||
switch {
|
||||
case operand1.Token.Type != godata.ExpressionTokenLiteral:
|
||||
fallthrough
|
||||
case operand1.Token.Value != "userType":
|
||||
fallthrough
|
||||
case operand2.Token.Type != godata.ExpressionTokenString:
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
|
||||
// unquote
|
||||
value := strings.Trim(operand2.Token.Value, "'")
|
||||
switch value {
|
||||
case "Member", "Guest":
|
||||
return g.identityBackend.GetUsers(ctx, req)
|
||||
case "Federated":
|
||||
return g.searchOCMAcceptedUsers(ctx, req)
|
||||
}
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
|
||||
func (g Graph) applyFilterLessOrEqual(ctx context.Context, req *godata.GoDataRequest, filterRoot *godata.ParseNode) (users []*libregraph.User, err error) {
|
||||
// Assume that our identity backend is able to apply this filter
|
||||
return g.identityBackend.FilterUsers(ctx, req, filterRoot)
|
||||
}
|
||||
|
||||
func (g Graph) applyFilterLambda(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
|
||||
logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
if len(nodes) != 2 {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
// We only support the "any" operator for lambda queries for now
|
||||
if nodes[1].Token.Type != godata.ExpressionTokenLambda || nodes[1].Token.Value != "any" {
|
||||
logger.Debug().Str("Token", nodes[1].Token.Value).Msg("unsupported lambda filter")
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
if nodes[0].Token.Type != godata.ExpressionTokenLiteral {
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
switch nodes[0].Token.Value {
|
||||
case "memberOf":
|
||||
return g.applyLambdaMemberOfAny(ctx, req, nodes[1].Children)
|
||||
case appRoleAssignments:
|
||||
return g.applyLambdaAppRoleAssignmentAny(ctx, req, nodes[1].Children)
|
||||
}
|
||||
logger.Debug().Str("Token", nodes[0].Token.Value).Msg("unsupported relation for lambda filter")
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
|
||||
func (g Graph) applyLambdaMemberOfAny(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
|
||||
if len(nodes) != 2 {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
|
||||
// First element is the "name" of the lambda function's parameter
|
||||
if nodes[0].Token.Type != godata.ExpressionTokenLiteral {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
|
||||
// We only support the 'eq' expression for now
|
||||
if nodes[1].Token.Type != godata.ExpressionTokenLogical || nodes[1].Token.Value != "eq" {
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
return g.applyMemberOfEq(ctx, req, nodes[1].Children)
|
||||
}
|
||||
|
||||
func (g Graph) applyMemberOfEq(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
|
||||
logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
if len(nodes) != 2 {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
|
||||
if nodes[0].Token.Type != godata.ExpressionTokenNav {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
|
||||
if len(nodes[0].Children) != 2 {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
|
||||
switch nodes[0].Children[1].Token.Value {
|
||||
case "id":
|
||||
filterValue, err := g.getUUIDTokenValue(ctx, nodes[1])
|
||||
if err != nil {
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
|
||||
logger.Debug().Str("property", nodes[0].Children[1].Token.Value).Str("value", filterValue).Msg("Filtering memberOf by group id")
|
||||
return g.identityBackend.GetGroupMembers(ctx, filterValue, req)
|
||||
default:
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
}
|
||||
|
||||
func (g Graph) applyLambdaAppRoleAssignmentAny(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
|
||||
if len(nodes) != 2 {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
|
||||
// First element is the "name" of the lambda function's parameter
|
||||
if nodes[0].Token.Type != godata.ExpressionTokenLiteral {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
|
||||
// We only support the 'eq' expression for now
|
||||
if nodes[1].Token.Type != godata.ExpressionTokenLogical || nodes[1].Token.Value != "eq" {
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
return g.applyAppRoleAssignmentEq(ctx, req, nodes[1].Children)
|
||||
}
|
||||
|
||||
func (g Graph) applyAppRoleAssignmentEq(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
|
||||
logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
if len(nodes) != 2 {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
|
||||
if nodes[0].Token.Type != godata.ExpressionTokenNav {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
|
||||
if len(nodes[0].Children) != 2 {
|
||||
return users, invalidFilterError()
|
||||
}
|
||||
|
||||
if nodes[0].Children[1].Token.Value == appRoleID {
|
||||
filterValue, err := g.getUUIDTokenValue(ctx, nodes[1])
|
||||
if err != nil {
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
|
||||
logger.Debug().Str("property", nodes[0].Children[1].Token.Value).Str("value", filterValue).Msg("Filtering appRoleAssignments by appRoleId")
|
||||
if users, err = g.identityBackend.GetUsers(ctx, req); err != nil {
|
||||
return users, err
|
||||
}
|
||||
|
||||
return g.filterUsersByAppRoleID(ctx, req, filterValue, users)
|
||||
}
|
||||
return users, unsupportedFilterError()
|
||||
}
|
||||
|
||||
func (g Graph) filterUsersByAppRoleID(ctx context.Context, req *godata.GoDataRequest, id string, users []*libregraph.User) ([]*libregraph.User, error) {
|
||||
// We're using a map for the results here, in order to avoid returning
|
||||
// a user twice. The settings API, still has an issue that causes it to
|
||||
// duplicate some assignments on restart:
|
||||
// https://github.com/owncloud/ocis/issues/3432
|
||||
usersByIdMap := make(map[string]*libregraph.User, len(users))
|
||||
for _, user := range users {
|
||||
usersByIdMap[user.GetId()] = user
|
||||
}
|
||||
|
||||
var expand bool
|
||||
if exp := req.Query.GetExpand(); exp != nil {
|
||||
for _, item := range exp.ExpandItems {
|
||||
if item.Path[0].Value == appRoleAssignments {
|
||||
expand = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assignmentsForRole, err := g.roleService.ListRoleAssignmentsFiltered(
|
||||
ctx,
|
||||
&settingssvc.ListRoleAssignmentsFilteredRequest{
|
||||
Filters: []*settingsmsg.UserRoleAssignmentFilter{
|
||||
{
|
||||
Type: settingsmsg.UserRoleAssignmentFilter_TYPE_ROLE,
|
||||
Term: &settingsmsg.UserRoleAssignmentFilter_RoleId{RoleId: id},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return users, err
|
||||
}
|
||||
resultUsers := make([]*libregraph.User, 0, len(assignmentsForRole.GetAssignments()))
|
||||
for _, assignment := range assignmentsForRole.GetAssignments() {
|
||||
if user, ok := usersByIdMap[assignment.GetAccountUuid()]; ok {
|
||||
if expand {
|
||||
user.AppRoleAssignments = []libregraph.AppRoleAssignment{g.assignmentToAppRoleAssignment(assignment)}
|
||||
}
|
||||
resultUsers = append(resultUsers, user)
|
||||
}
|
||||
}
|
||||
return resultUsers, nil
|
||||
}
|
||||
|
||||
func (g Graph) getUUIDTokenValue(ctx context.Context, node *godata.ParseNode) (string, error) {
|
||||
var value string
|
||||
switch node.Token.Type {
|
||||
case godata.ExpressionTokenGuid:
|
||||
value = node.Token.Value
|
||||
case godata.ExpressionTokenString:
|
||||
// unquote
|
||||
value = strings.Trim(node.Token.Value, "'")
|
||||
default:
|
||||
return "", unsupportedFilterError()
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (g Graph) isAppRoleAssignmentFilter(ctx context.Context, node *godata.ParseNode) (match bool, property string, filter string) {
|
||||
if node.Token.Type != godata.ExpressionTokenLambdaNav {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
if len(node.Children) != 2 {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
if node.Children[0].Token.Type != godata.ExpressionTokenLiteral || node.Children[0].Token.Value != appRoleAssignments {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
if node.Children[1].Token.Type != godata.ExpressionTokenLambda || node.Children[1].Token.Value != "any" {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
if len(node.Children[1].Children) != 2 {
|
||||
return false, "", ""
|
||||
}
|
||||
lambdaParam := node.Children[1].Children
|
||||
// We only support the 'eq' expression for now
|
||||
if lambdaParam[1].Token.Type != godata.ExpressionTokenLogical || lambdaParam[1].Token.Value != "eq" {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
if len(lambdaParam[1].Children) != 2 {
|
||||
return false, "", ""
|
||||
}
|
||||
expression := lambdaParam[1].Children
|
||||
if expression[0].Token.Type != godata.ExpressionTokenNav || expression[0].Token.Value != "/" {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
if len(expression[0].Children) != 2 {
|
||||
return false, "", ""
|
||||
}
|
||||
if expression[0].Children[1].Token.Value != appRoleID {
|
||||
return false, "", ""
|
||||
}
|
||||
filterValue, err := g.getUUIDTokenValue(ctx, expression[1])
|
||||
if err != nil {
|
||||
return false, "", ""
|
||||
}
|
||||
return true, appRoleID, filterValue
|
||||
}
|
||||
|
||||
func userSliceToMap(users []*libregraph.User) map[string]*libregraph.User {
|
||||
resMap := make(map[string]*libregraph.User, len(users))
|
||||
for _, user := range users {
|
||||
resMap[user.GetId()] = user
|
||||
}
|
||||
return resMap
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,837 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
cs3User "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/graph/pkg/errorcode"
|
||||
"github.com/qsfera/server/services/graph/pkg/identity/cache"
|
||||
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
|
||||
)
|
||||
|
||||
// StrictJSONUnmarshal is a wrapper around json.Unmarshal that returns an error if the json contains unknown fields.
|
||||
func StrictJSONUnmarshal(r io.Reader, v any) error {
|
||||
dec := json.NewDecoder(r)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
// IsSpaceRoot returns true if the resourceID is a space root.
|
||||
func IsSpaceRoot(rid *storageprovider.ResourceId) bool {
|
||||
if rid == nil {
|
||||
return false
|
||||
}
|
||||
if rid.GetSpaceId() == "" || rid.GetOpaqueId() == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return rid.GetSpaceId() == rid.GetOpaqueId()
|
||||
}
|
||||
|
||||
// GetDriveAndItemIDParam parses the driveID and itemID from the request,
|
||||
// validates the common fields and returns the parsed IDs if ok.
|
||||
func GetDriveAndItemIDParam(r *http.Request, logger *log.Logger) (*storageprovider.ResourceId, *storageprovider.ResourceId, error) {
|
||||
empty := &storageprovider.ResourceId{}
|
||||
|
||||
driveID, err := parseIDParam(r, "driveID")
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not parse driveID")
|
||||
return empty, empty, errorcode.New(errorcode.InvalidRequest, "invalid driveID")
|
||||
}
|
||||
|
||||
itemID, err := parseIDParam(r, "itemID")
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not parse itemID")
|
||||
return empty, empty, errorcode.New(errorcode.InvalidRequest, "invalid itemID")
|
||||
}
|
||||
|
||||
if itemID.GetOpaqueId() == "" {
|
||||
logger.Debug().Interface("driveID", &driveID).Interface("itemID", &itemID).Msg("empty item opaqueID")
|
||||
return empty, empty, errorcode.New(errorcode.InvalidRequest, "invalid itemID")
|
||||
}
|
||||
|
||||
if driveID.GetStorageId() != itemID.GetStorageId() || driveID.GetSpaceId() != itemID.GetSpaceId() {
|
||||
logger.Debug().Interface("driveID", &driveID).Interface("itemID", &itemID).Msg("driveID and itemID do not match")
|
||||
return empty, empty, errorcode.New(errorcode.ItemNotFound, "driveID and itemID do not match")
|
||||
}
|
||||
|
||||
return &driveID, &itemID, nil
|
||||
}
|
||||
|
||||
// GetGatewayClient returns a gateway client from the gatewaySelector.
|
||||
func (g Graph) GetGatewayClient(w http.ResponseWriter, r *http.Request) (gateway.GatewayAPIClient, bool) {
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
g.logger.Debug().Err(err).Msg("selecting gatewaySelector failed")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return gatewayClient, true
|
||||
}
|
||||
|
||||
// IsShareJail returns true if given id is a share jail id.
|
||||
func IsShareJail(id *storageprovider.ResourceId) bool {
|
||||
return id.GetStorageId() == utils.ShareStorageProviderID && id.GetSpaceId() == utils.ShareStorageSpaceID
|
||||
}
|
||||
|
||||
// userIdToIdentity looks the user for the supplied id using the cache and returns it
|
||||
// as a libregraph.Identity
|
||||
func userIdToIdentity(ctx context.Context, cache cache.IdentityCache, tennantId, userID string) (libregraph.Identity, error) {
|
||||
identity := libregraph.Identity{
|
||||
Id: libregraph.PtrString(userID),
|
||||
}
|
||||
user, err := cache.GetUser(ctx, tennantId, userID)
|
||||
if err == nil {
|
||||
identity.SetDisplayName(user.GetDisplayName())
|
||||
identity.SetLibreGraphUserType(user.GetUserType())
|
||||
}
|
||||
return identity, err
|
||||
}
|
||||
|
||||
// federatedIdToIdentity looks the user for the supplied id using the cache and returns it
|
||||
// as a libregraph.Identity
|
||||
func federatedIdToIdentity(ctx context.Context, cache cache.IdentityCache, cs3UserID *cs3User.UserId) (libregraph.Identity, error) {
|
||||
userID := fmt.Sprintf("%s@%s", cs3UserID.GetOpaqueId(), cs3UserID.GetIdp())
|
||||
identity := libregraph.Identity{
|
||||
Id: libregraph.PtrString(userID),
|
||||
LibreGraphUserType: libregraph.PtrString("Federated"),
|
||||
}
|
||||
user, err := cache.GetAcceptedUser(ctx, userID)
|
||||
if err == nil {
|
||||
identity.SetDisplayName(user.GetDisplayName())
|
||||
identity.SetLibreGraphUserType(user.GetUserType())
|
||||
}
|
||||
return identity, err
|
||||
}
|
||||
|
||||
// cs3UserIdToIdentity looks up the user for the supplied cs3 userid using the cache and returns it
|
||||
// as a libregraph.Identity. Skips the user lookup if the id type is USER_TYPE_SPACE_OWNER
|
||||
func cs3UserIdToIdentity(ctx context.Context, cache cache.IdentityCache, cs3UserID *cs3User.UserId) (libregraph.Identity, error) {
|
||||
if cs3UserID.GetType() == cs3User.UserType_USER_TYPE_FEDERATED {
|
||||
return federatedIdToIdentity(ctx, cache, cs3UserID)
|
||||
}
|
||||
if cs3UserID.GetType() != cs3User.UserType_USER_TYPE_SPACE_OWNER {
|
||||
return userIdToIdentity(ctx, cache, cs3UserID.GetTenantId(), cs3UserID.GetOpaqueId())
|
||||
}
|
||||
return libregraph.Identity{Id: libregraph.PtrString(cs3UserID.GetOpaqueId())}, nil
|
||||
}
|
||||
|
||||
// groupIdToIdentity looks up the group for the supplied cs3 groupid using the cache and returns it
|
||||
// as a libregraph.Identity.
|
||||
func groupIdToIdentity(ctx context.Context, cache cache.IdentityCache, groupID string) (libregraph.Identity, error) {
|
||||
identity := libregraph.Identity{
|
||||
Id: libregraph.PtrString(groupID),
|
||||
}
|
||||
group, err := cache.GetGroup(ctx, groupID)
|
||||
if err == nil {
|
||||
identity.SetDisplayName(group.GetDisplayName())
|
||||
}
|
||||
return identity, err
|
||||
}
|
||||
|
||||
// identitySetToSpacePermissionID generates an Id for a permission from an identitySet. In libregraph
|
||||
// permissions need to have an id. For user share permission we just use the cs3 share id as the permission-id
|
||||
// As permissions on space to not map to a cs3 share we need something else of the ids. So we just
|
||||
// construct the id for the id of the user or group that the permission applies to and prefix that
|
||||
// with a "u:" for userids and "g:" for group ids.
|
||||
func identitySetToSpacePermissionID(identitySet libregraph.SharePointIdentitySet) (id string) {
|
||||
switch {
|
||||
case identitySet.HasUser():
|
||||
id = "u:" + identitySet.User.GetId()
|
||||
case identitySet.HasGroup():
|
||||
id = "g:" + identitySet.Group.GetId()
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func cs3ReceivedSharesToDriveItems(ctx context.Context,
|
||||
logger *log.Logger,
|
||||
gatewayClient gateway.GatewayAPIClient,
|
||||
identityCache cache.IdentityCache,
|
||||
receivedShares []*collaboration.ReceivedShare,
|
||||
availableRoles []*libregraph.UnifiedRoleDefinition,
|
||||
) ([]libregraph.DriveItem, error) {
|
||||
|
||||
group := new(errgroup.Group)
|
||||
// Set max concurrency
|
||||
group.SetLimit(10)
|
||||
|
||||
receivedSharesByResourceID := make(map[string][]*collaboration.ReceivedShare, len(receivedShares))
|
||||
for _, receivedShare := range receivedShares {
|
||||
if receivedShare == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
rIDStr := storagespace.FormatResourceID(receivedShare.GetShare().GetResourceId())
|
||||
receivedSharesByResourceID[rIDStr] = append(receivedSharesByResourceID[rIDStr], receivedShare)
|
||||
}
|
||||
|
||||
ch := make(chan libregraph.DriveItem, len(receivedSharesByResourceID))
|
||||
for _, receivedSharesForResource := range receivedSharesByResourceID {
|
||||
receivedShares := receivedSharesForResource
|
||||
|
||||
group.Go(func() error {
|
||||
var err error // redeclare
|
||||
shareStat, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{
|
||||
Ref: &storageprovider.Reference{
|
||||
ResourceId: receivedShares[0].GetShare().GetResourceId(),
|
||||
},
|
||||
})
|
||||
|
||||
if err := errorcode.FromCS3Status(shareStat.GetStatus(), err); err != nil {
|
||||
logger.Debug().Err(err).
|
||||
Str("shareid", receivedShares[0].GetShare().GetId().GetOpaqueId()).
|
||||
Str("resourceid", storagespace.FormatResourceID(receivedShares[0].GetShare().GetResourceId())).
|
||||
Msg("could not stat received share, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
driveItem, err := fillDriveItemPropertiesFromReceivedShare(ctx, logger, identityCache, receivedShares, shareStat.GetInfo(), availableRoles)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).
|
||||
Str("shareid", receivedShares[0].GetShare().GetId().GetOpaqueId()).
|
||||
Str("resourceid", storagespace.FormatResourceID(receivedShares[0].GetShare().GetResourceId())).
|
||||
Msg("could not fill drive item properties from received share, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !driveItem.HasUIHidden() {
|
||||
driveItem.SetUIHidden(false)
|
||||
}
|
||||
if !driveItem.HasClientSynchronize() {
|
||||
driveItem.SetClientSynchronize(false)
|
||||
if name := shareStat.GetInfo().GetName(); name != "" {
|
||||
driveItem.SetName(name)
|
||||
}
|
||||
}
|
||||
|
||||
remoteItem := driveItem.RemoteItem
|
||||
{
|
||||
if id := shareStat.GetInfo().GetId(); id != nil {
|
||||
remoteItem.SetId(storagespace.FormatResourceID(id))
|
||||
}
|
||||
|
||||
if name := shareStat.GetInfo().GetName(); name != "" {
|
||||
remoteItem.SetName(name)
|
||||
}
|
||||
|
||||
if etag := shareStat.GetInfo().GetEtag(); etag != "" {
|
||||
remoteItem.SetETag(etag)
|
||||
}
|
||||
|
||||
if mTime := shareStat.GetInfo().GetMtime(); mTime != nil {
|
||||
remoteItem.SetLastModifiedDateTime(cs3TimestampToTime(mTime))
|
||||
}
|
||||
|
||||
if size := shareStat.GetInfo().GetSize(); size != 0 {
|
||||
remoteItem.SetSize(int64(size))
|
||||
}
|
||||
|
||||
parentReference := libregraph.NewItemReference()
|
||||
if spaceType := shareStat.GetInfo().GetSpace().GetSpaceType(); spaceType != "" {
|
||||
parentReference.SetDriveType(spaceType)
|
||||
}
|
||||
|
||||
if root := shareStat.GetInfo().GetSpace().GetRoot(); root != nil {
|
||||
parentReference.SetDriveId(storagespace.FormatResourceID(root))
|
||||
}
|
||||
if !reflect.ValueOf(*parentReference).IsZero() {
|
||||
remoteItem.ParentReference = parentReference
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// the parentReference of the outer driveItem should be the drive
|
||||
// containing the mountpoint i.e. the share jail
|
||||
driveItem.ParentReference = libregraph.NewItemReference()
|
||||
driveItem.ParentReference.SetDriveType(_spaceTypeVirtual)
|
||||
driveItem.ParentReference.SetDriveId(storagespace.FormatStorageID(utils.ShareStorageProviderID, utils.ShareStorageSpaceID))
|
||||
driveItem.ParentReference.SetId(storagespace.FormatResourceID(&storageprovider.ResourceId{
|
||||
StorageId: utils.ShareStorageProviderID,
|
||||
OpaqueId: utils.ShareStorageSpaceID,
|
||||
SpaceId: utils.ShareStorageSpaceID,
|
||||
}))
|
||||
if etag := shareStat.GetInfo().GetEtag(); etag != "" {
|
||||
driveItem.SetETag(etag)
|
||||
}
|
||||
|
||||
// connect the dots
|
||||
{
|
||||
if mTime := shareStat.GetInfo().GetMtime(); mTime != nil {
|
||||
t := cs3TimestampToTime(mTime)
|
||||
|
||||
driveItem.SetLastModifiedDateTime(t)
|
||||
remoteItem.SetLastModifiedDateTime(t)
|
||||
}
|
||||
|
||||
if size := shareStat.GetInfo().GetSize(); size != 0 {
|
||||
s := int64(size)
|
||||
|
||||
driveItem.SetSize(s)
|
||||
remoteItem.SetSize(s)
|
||||
}
|
||||
|
||||
if userID := shareStat.GetInfo().GetOwner(); userID != nil && userID.Type != cs3User.UserType_USER_TYPE_SPACE_OWNER {
|
||||
identity, err := cs3UserIdToIdentity(ctx, identityCache, userID)
|
||||
if err != nil {
|
||||
// TODO: define a proper error behavior here. We don't
|
||||
// want the whole request to fail just because a single
|
||||
// resource owner couldn't be resolved. But, should we
|
||||
// really return the affected share in the response?
|
||||
// For now we just log a warning. The returned
|
||||
// identitySet will just contain the userid.
|
||||
logger.Warn().Err(err).Str("userid", userID.String()).Msg("could not get owner of shared resource")
|
||||
}
|
||||
|
||||
remoteItem.SetCreatedBy(libregraph.IdentitySet{User: &identity})
|
||||
driveItem.SetCreatedBy(libregraph.IdentitySet{User: &identity})
|
||||
}
|
||||
switch info := shareStat.GetInfo(); {
|
||||
case info.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER:
|
||||
folder := libregraph.NewFolder()
|
||||
|
||||
remoteItem.Folder = folder
|
||||
driveItem.Folder = folder
|
||||
case info.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_FILE:
|
||||
file := libregraph.NewOpenGraphFile()
|
||||
|
||||
if mimeType := info.GetMimeType(); mimeType != "" {
|
||||
file.MimeType = &mimeType
|
||||
}
|
||||
|
||||
remoteItem.File = file
|
||||
driveItem.File = file
|
||||
}
|
||||
}
|
||||
|
||||
ch <- *driveItem
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var err error
|
||||
// wait for concurrent requests to finish
|
||||
go func() {
|
||||
err = group.Wait()
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
driveItems := make([]libregraph.DriveItem, 0, len(receivedSharesByResourceID))
|
||||
for di := range ch {
|
||||
driveItems = append(driveItems, di)
|
||||
}
|
||||
|
||||
return driveItems, err
|
||||
}
|
||||
|
||||
func fillDriveItemPropertiesFromReceivedShare(ctx context.Context, logger *log.Logger,
|
||||
identityCache cache.IdentityCache, receivedShares []*collaboration.ReceivedShare,
|
||||
resourceInfo *storageprovider.ResourceInfo, availableRoles []*libregraph.UnifiedRoleDefinition) (*libregraph.DriveItem, error) {
|
||||
|
||||
driveItem := libregraph.NewDriveItem()
|
||||
permissions := make([]libregraph.Permission, 0, len(receivedShares))
|
||||
|
||||
var oldestReceivedShare *collaboration.ReceivedShare
|
||||
for _, receivedShare := range receivedShares {
|
||||
switch {
|
||||
case oldestReceivedShare == nil:
|
||||
fallthrough
|
||||
case utils.TSToTime(receivedShare.GetShare().GetCtime()).Before(utils.TSToTime(oldestReceivedShare.GetShare().GetCtime())):
|
||||
oldestReceivedShare = receivedShare
|
||||
}
|
||||
|
||||
permission, err := cs3ReceivedShareToLibreGraphPermissions(ctx, logger, identityCache, receivedShare, resourceInfo, availableRoles)
|
||||
if err != nil {
|
||||
return driveItem, err
|
||||
}
|
||||
|
||||
// If at least one of the shares was accepted, we consider the driveItem's synchronized
|
||||
// flag enabled.
|
||||
// Also we use the Mountpoint name of the first accepted mountpoint as the name for
|
||||
// the driveItem
|
||||
if receivedShare.GetState() == collaboration.ShareState_SHARE_STATE_ACCEPTED {
|
||||
driveItem.SetClientSynchronize(true)
|
||||
if name := receivedShare.GetMountPoint().GetPath(); name != "" && driveItem.GetName() == "" {
|
||||
driveItem.SetName(receivedShare.GetMountPoint().GetPath())
|
||||
}
|
||||
}
|
||||
|
||||
// if at least one share is marked as hidden, consider the whole driveItem to be hidden
|
||||
if receivedShare.GetHidden() {
|
||||
driveItem.SetUIHidden(true)
|
||||
}
|
||||
|
||||
if userID := receivedShare.GetShare().GetCreator(); userID != nil {
|
||||
identity, err := cs3UserIdToIdentity(ctx, identityCache, userID)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Str("userid", userID.String()).Msg("could not get creator of the share")
|
||||
}
|
||||
|
||||
permission.SetInvitation(
|
||||
libregraph.SharingInvitation{
|
||||
InvitedBy: &libregraph.IdentitySet{
|
||||
User: &identity,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
permissions = append(permissions, *permission)
|
||||
// To stay compatible with the usershareprovider and the webdav
|
||||
// service the id of the driveItem is composed of the StorageID and
|
||||
// SpaceID of the sharestorage appended with the opaque ID of
|
||||
// the oldest share for the resource:
|
||||
// '<sharestorageid>$<sharespaceid>!<share-opaque-id>
|
||||
// Note: This means that the driveitem ID will change when the oldest
|
||||
// share is removed. It would be good to have are more stable ID here (e.g.
|
||||
// derived from the shared resource's ID. But as we need to use the same
|
||||
// ID across all services this means we needed to make similar adjustments
|
||||
// to the sharejail (usershareprovider, webdav). Which we can't currently do
|
||||
// as some clients rely on the IDs used there having a special format.
|
||||
driveItem.SetId(storagespace.FormatResourceID(&storageprovider.ResourceId{
|
||||
StorageId: utils.ShareStorageProviderID,
|
||||
OpaqueId: oldestReceivedShare.GetShare().GetId().GetOpaqueId(),
|
||||
SpaceId: utils.ShareStorageSpaceID,
|
||||
}))
|
||||
|
||||
}
|
||||
driveItem.RemoteItem = libregraph.NewRemoteItem()
|
||||
driveItem.RemoteItem.Permissions = permissions
|
||||
return driveItem, nil
|
||||
}
|
||||
|
||||
func cs3ReceivedShareToLibreGraphPermissions(ctx context.Context, logger *log.Logger,
|
||||
identityCache cache.IdentityCache, receivedShare *collaboration.ReceivedShare,
|
||||
resourceInfo *storageprovider.ResourceInfo, availableRoles []*libregraph.UnifiedRoleDefinition) (*libregraph.Permission, error) {
|
||||
permission := libregraph.NewPermission()
|
||||
if id := receivedShare.GetShare().GetId().GetOpaqueId(); id != "" {
|
||||
permission.SetId(id)
|
||||
}
|
||||
|
||||
if expiration := receivedShare.GetShare().GetExpiration(); expiration != nil {
|
||||
permission.SetExpirationDateTime(cs3TimestampToTime(expiration))
|
||||
}
|
||||
|
||||
if cTime := receivedShare.GetShare().GetCtime(); cTime != nil {
|
||||
permission.SetCreatedDateTime(cs3TimestampToTime(cTime))
|
||||
}
|
||||
|
||||
if permissionSet := receivedShare.GetShare().GetPermissions().GetPermissions(); permissionSet != nil {
|
||||
condition, err := roleConditionForResourceType(resourceInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role := unifiedrole.CS3ResourcePermissionsToRole(
|
||||
availableRoles,
|
||||
permissionSet,
|
||||
condition,
|
||||
false,
|
||||
)
|
||||
if role != nil {
|
||||
permission.SetRoles([]string{role.GetId()})
|
||||
}
|
||||
|
||||
actions := unifiedrole.CS3ResourcePermissionsToLibregraphActions(permissionSet)
|
||||
|
||||
// actions only make sense if no role is set
|
||||
if role == nil && len(actions) > 0 {
|
||||
permission.SetLibreGraphPermissionsActions(actions)
|
||||
}
|
||||
}
|
||||
switch grantee := receivedShare.GetShare().GetGrantee(); {
|
||||
case grantee.GetType() == storageprovider.GranteeType_GRANTEE_TYPE_USER:
|
||||
user, err := cs3UserIdToIdentity(ctx, identityCache, grantee.GetUserId())
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get user")
|
||||
return nil, err
|
||||
}
|
||||
permission.SetGrantedToV2(libregraph.SharePointIdentitySet{User: &user})
|
||||
case grantee.GetType() == storageprovider.GranteeType_GRANTEE_TYPE_GROUP:
|
||||
group, err := groupIdToIdentity(ctx, identityCache, grantee.GetGroupId().GetOpaqueId())
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get group")
|
||||
return nil, err
|
||||
}
|
||||
permission.SetGrantedToV2(libregraph.SharePointIdentitySet{Group: &group})
|
||||
}
|
||||
|
||||
return permission, nil
|
||||
}
|
||||
|
||||
func roleConditionForResourceType(ri *storageprovider.ResourceInfo) (string, error) {
|
||||
switch {
|
||||
case utils.ResourceIDEqual(ri.GetSpace().GetRoot(), ri.GetId()):
|
||||
return unifiedrole.UnifiedRoleConditionDrive, nil
|
||||
case ri.Type == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER:
|
||||
return unifiedrole.UnifiedRoleConditionFolder, nil
|
||||
case ri.Type == storageprovider.ResourceType_RESOURCE_TYPE_FILE:
|
||||
return unifiedrole.UnifiedRoleConditionFile, nil
|
||||
default:
|
||||
return "", errorcode.New(errorcode.InvalidRequest, "unsupported resource type")
|
||||
}
|
||||
}
|
||||
|
||||
func federatedRoleConditionForResourceType(ri *storageprovider.ResourceInfo) (string, error) {
|
||||
switch ri.GetType() {
|
||||
case storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER:
|
||||
return unifiedrole.UnifiedRoleConditionFolderFederatedUser, nil
|
||||
case storageprovider.ResourceType_RESOURCE_TYPE_FILE:
|
||||
return unifiedrole.UnifiedRoleConditionFileFederatedUser, nil
|
||||
default:
|
||||
return "", errorcode.New(errorcode.InvalidRequest, "unsupported resource type for federated role")
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractShareIdFromResourceId is a bit of a hack.
|
||||
// We should not rely on a specific format of the item id.
|
||||
// But currently there is no other way to get the ShareID.
|
||||
func ExtractShareIdFromResourceId(rid *storageprovider.ResourceId) *collaboration.ShareId {
|
||||
return &collaboration.ShareId{
|
||||
OpaqueId: rid.GetOpaqueId(),
|
||||
}
|
||||
}
|
||||
|
||||
func cs3ReceivedOCMSharesToDriveItems(ctx context.Context,
|
||||
logger *log.Logger,
|
||||
gatewayClient gateway.GatewayAPIClient,
|
||||
identityCache cache.IdentityCache,
|
||||
receivedShares []*ocm.ReceivedShare, availableRoles []*libregraph.UnifiedRoleDefinition) ([]libregraph.DriveItem, error) {
|
||||
|
||||
group := new(errgroup.Group)
|
||||
// Set max concurrency
|
||||
group.SetLimit(10)
|
||||
|
||||
receivedSharesByResourceID := make(map[string][]*ocm.ReceivedShare, len(receivedShares))
|
||||
for _, receivedShare := range receivedShares {
|
||||
rIDStr := receivedShare.GetRemoteShareId()
|
||||
receivedSharesByResourceID[rIDStr] = append(receivedSharesByResourceID[rIDStr], receivedShare)
|
||||
}
|
||||
|
||||
ch := make(chan libregraph.DriveItem, len(receivedSharesByResourceID))
|
||||
for _, receivedSharesForResource := range receivedSharesByResourceID {
|
||||
receivedShares := receivedSharesForResource
|
||||
|
||||
group.Go(func() error {
|
||||
var err error // redeclare
|
||||
|
||||
// for OCM shares the opaqueID is the '/' for shared directories and '/filename' for
|
||||
// file shares
|
||||
resOpaqueID := "/"
|
||||
if receivedShares[0].GetResourceType() == storageprovider.ResourceType_RESOURCE_TYPE_FILE {
|
||||
resOpaqueID += receivedShares[0].GetName()
|
||||
}
|
||||
|
||||
shareStat, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{
|
||||
Ref: &storageprovider.Reference{
|
||||
ResourceId: &storageprovider.ResourceId{
|
||||
// TODO maybe the reference is wrong
|
||||
StorageId: utils.OCMStorageProviderID,
|
||||
SpaceId: receivedShares[0].GetId().GetOpaqueId(),
|
||||
OpaqueId: base64.StdEncoding.EncodeToString([]byte(resOpaqueID)),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := errorcode.FromCS3Status(shareStat.GetStatus(), err); err != nil {
|
||||
logger.Debug().Err(err).
|
||||
Str("shareid", receivedShares[0].GetId().GetOpaqueId()).
|
||||
Str("remoteshareid", receivedShares[0].GetRemoteShareId()).
|
||||
Msg("could not stat received ocm share, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
driveItem, err := fillDriveItemPropertiesFromReceivedOCMShare(ctx, logger, identityCache, receivedShares, shareStat.GetInfo(), availableRoles)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).
|
||||
Str("shareid", receivedShares[0].GetId().GetOpaqueId()).
|
||||
Str("remoteshareid", receivedShares[0].GetRemoteShareId()).
|
||||
Msg("could not fill drive item properties from received ocm share, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !driveItem.HasUIHidden() {
|
||||
driveItem.SetUIHidden(false)
|
||||
}
|
||||
if !driveItem.HasClientSynchronize() {
|
||||
driveItem.SetClientSynchronize(false)
|
||||
if name := shareStat.GetInfo().GetName(); name != "" {
|
||||
driveItem.SetName(name) // FIXME name is not set???
|
||||
}
|
||||
}
|
||||
|
||||
remoteItem := driveItem.RemoteItem
|
||||
{
|
||||
if id := shareStat.GetInfo().GetId(); id != nil {
|
||||
remoteItem.SetId(storagespace.FormatResourceID(id))
|
||||
}
|
||||
|
||||
if name := shareStat.GetInfo().GetName(); name != "" {
|
||||
remoteItem.SetName(name)
|
||||
}
|
||||
|
||||
if etag := shareStat.GetInfo().GetEtag(); etag != "" {
|
||||
remoteItem.SetETag(etag)
|
||||
}
|
||||
|
||||
if mTime := shareStat.GetInfo().GetMtime(); mTime != nil {
|
||||
remoteItem.SetLastModifiedDateTime(cs3TimestampToTime(mTime))
|
||||
}
|
||||
|
||||
if size := shareStat.GetInfo().GetSize(); size != 0 {
|
||||
remoteItem.SetSize(int64(size))
|
||||
}
|
||||
|
||||
parentReference := libregraph.NewItemReference()
|
||||
if spaceType := shareStat.GetInfo().GetSpace().GetSpaceType(); spaceType != "" {
|
||||
parentReference.SetDriveType(spaceType)
|
||||
}
|
||||
|
||||
if root := shareStat.GetInfo().GetSpace().GetRoot(); root != nil {
|
||||
parentReference.SetDriveId(storagespace.FormatResourceID(root))
|
||||
}
|
||||
if !reflect.ValueOf(*parentReference).IsZero() {
|
||||
remoteItem.ParentReference = parentReference
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// the parentReference of the outer driveItem should be the drive
|
||||
// containing the mountpoint i.e. the share jail
|
||||
driveItem.ParentReference = libregraph.NewItemReference()
|
||||
driveItem.ParentReference.SetDriveType("virtual")
|
||||
driveItem.ParentReference.SetDriveId(storagespace.FormatStorageID(utils.ShareStorageProviderID, utils.ShareStorageSpaceID))
|
||||
driveItem.ParentReference.SetId(storagespace.FormatResourceID(&storageprovider.ResourceId{
|
||||
StorageId: utils.ShareStorageProviderID,
|
||||
OpaqueId: utils.ShareStorageSpaceID,
|
||||
SpaceId: utils.ShareStorageSpaceID,
|
||||
}))
|
||||
if etag := shareStat.GetInfo().GetEtag(); etag != "" {
|
||||
driveItem.SetETag(etag)
|
||||
}
|
||||
|
||||
// connect the dots
|
||||
{
|
||||
if mTime := shareStat.GetInfo().GetMtime(); mTime != nil {
|
||||
t := cs3TimestampToTime(mTime)
|
||||
|
||||
driveItem.SetLastModifiedDateTime(t)
|
||||
remoteItem.SetLastModifiedDateTime(t)
|
||||
}
|
||||
|
||||
if size := shareStat.GetInfo().GetSize(); size != 0 {
|
||||
s := int64(size)
|
||||
|
||||
driveItem.SetSize(s)
|
||||
remoteItem.SetSize(s)
|
||||
}
|
||||
|
||||
if userID := shareStat.GetInfo().GetOwner(); userID != nil && userID.Type != cs3User.UserType_USER_TYPE_SPACE_OWNER {
|
||||
identity, err := cs3UserIdToIdentity(ctx, identityCache, userID)
|
||||
if err != nil {
|
||||
// TODO: define a proper error behavior here. We don't
|
||||
// want the whole request to fail just because a single
|
||||
// resource owner couldn't be resolved. But, should we
|
||||
// really return the affected share in the response?
|
||||
// For now we just log a warning. The returned
|
||||
// identitySet will just contain the userid.
|
||||
logger.Warn().Err(err).Str("userid", userID.String()).Msg("could not get owner of shared resource")
|
||||
}
|
||||
|
||||
remoteItem.SetCreatedBy(libregraph.IdentitySet{User: &identity})
|
||||
driveItem.SetCreatedBy(libregraph.IdentitySet{User: &identity})
|
||||
}
|
||||
switch info := shareStat.GetInfo(); {
|
||||
case info.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER:
|
||||
folder := libregraph.NewFolder()
|
||||
|
||||
remoteItem.Folder = folder
|
||||
driveItem.Folder = folder
|
||||
case info.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_FILE:
|
||||
file := libregraph.NewOpenGraphFile()
|
||||
|
||||
if mimeType := info.GetMimeType(); mimeType != "" {
|
||||
file.MimeType = &mimeType
|
||||
}
|
||||
|
||||
remoteItem.File = file
|
||||
driveItem.File = file
|
||||
}
|
||||
}
|
||||
|
||||
ch <- *driveItem
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var err error
|
||||
// wait for concurrent requests to finish
|
||||
go func() {
|
||||
err = group.Wait()
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
driveItems := make([]libregraph.DriveItem, 0, len(receivedSharesByResourceID))
|
||||
for di := range ch {
|
||||
driveItems = append(driveItems, di)
|
||||
}
|
||||
|
||||
return driveItems, err
|
||||
}
|
||||
|
||||
func fillDriveItemPropertiesFromReceivedOCMShare(ctx context.Context, logger *log.Logger,
|
||||
identityCache cache.IdentityCache, receivedShares []*ocm.ReceivedShare,
|
||||
resourceInfo *storageprovider.ResourceInfo, availableRoles []*libregraph.UnifiedRoleDefinition) (*libregraph.DriveItem, error) {
|
||||
|
||||
driveItem := libregraph.NewDriveItem()
|
||||
permissions := make([]libregraph.Permission, 0, len(receivedShares))
|
||||
|
||||
var oldestReceivedShare *ocm.ReceivedShare
|
||||
for _, receivedShare := range receivedShares {
|
||||
switch {
|
||||
case oldestReceivedShare == nil:
|
||||
fallthrough
|
||||
case utils.TSToTime(receivedShare.GetCtime()).Before(utils.TSToTime(oldestReceivedShare.GetCtime())):
|
||||
oldestReceivedShare = receivedShare
|
||||
}
|
||||
|
||||
permission, err := cs3ReceivedOCMShareToLibreGraphPermissions(ctx, logger, identityCache, receivedShare, resourceInfo, availableRoles)
|
||||
if err != nil {
|
||||
return driveItem, err
|
||||
}
|
||||
|
||||
driveItem.SetName(resourceInfo.GetName())
|
||||
|
||||
// If at least one of the shares was accepted, we consider the driveItem's synchronized
|
||||
// flag enabled.
|
||||
// Also we use the Mountpoint name of the first accepted mountpoint as the name for
|
||||
// the driveItem
|
||||
if receivedShare.GetState() == ocm.ShareState_SHARE_STATE_ACCEPTED {
|
||||
driveItem.SetClientSynchronize(true)
|
||||
if name := receivedShare.GetName(); name != "" && driveItem.GetName() == "" {
|
||||
driveItem.SetName(receivedShare.GetName())
|
||||
}
|
||||
}
|
||||
|
||||
// if at least one share is marked as hidden, consider the whole driveItem to be hidden
|
||||
/*
|
||||
if receivedShare.GetHidden() {
|
||||
driveItem.SetUIHidden(true)
|
||||
}
|
||||
*/
|
||||
|
||||
if userID := receivedShare.GetCreator(); userID != nil {
|
||||
identity, err := cs3UserIdToIdentity(ctx, identityCache, userID)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Str("userid", userID.String()).Msg("could not get creator of the ocm share")
|
||||
}
|
||||
|
||||
permission.SetInvitation(
|
||||
libregraph.SharingInvitation{
|
||||
InvitedBy: &libregraph.IdentitySet{
|
||||
User: &identity,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
permissions = append(permissions, *permission)
|
||||
// To stay compatible with the usershareprovider and the webdav
|
||||
// service the id of the driveItem is composed of the StorageID and
|
||||
// SpaceID of the sharestorage appended with the opaque ID of
|
||||
// the oldest share for the resource:
|
||||
// '<sharestorageid>$<sharespaceid>!<share-opaque-id>
|
||||
// Note: This means that the driveitem ID will change when the oldest
|
||||
// share is removed. It would be good to have are more stable ID here (e.g.
|
||||
// derived from the shared resource's ID. But as we need to use the same
|
||||
// ID across all services this means we needed to make similar adjustments
|
||||
// to the sharejail (usershareprovider, webdav). Which we can't currently do
|
||||
// as some clients rely on the IDs used there having a special format.
|
||||
driveItem.SetId(storagespace.FormatResourceID(&storageprovider.ResourceId{
|
||||
StorageId: utils.OCMStorageProviderID,
|
||||
SpaceId: utils.OCMStorageSpaceID,
|
||||
OpaqueId: oldestReceivedShare.GetRemoteShareId(),
|
||||
}))
|
||||
|
||||
}
|
||||
driveItem.RemoteItem = libregraph.NewRemoteItem()
|
||||
driveItem.RemoteItem.Permissions = permissions
|
||||
return driveItem, nil
|
||||
}
|
||||
|
||||
func cs3ReceivedOCMShareToLibreGraphPermissions(ctx context.Context, logger *log.Logger,
|
||||
identityCache cache.IdentityCache, receivedShare *ocm.ReceivedShare,
|
||||
resourceInfo *storageprovider.ResourceInfo, availableRoles []*libregraph.UnifiedRoleDefinition) (*libregraph.Permission, error) {
|
||||
permission := libregraph.NewPermission()
|
||||
if id := receivedShare.GetId().GetOpaqueId(); id != "" {
|
||||
permission.SetId(id)
|
||||
}
|
||||
|
||||
if cTime := receivedShare.GetCtime(); cTime != nil {
|
||||
permission.SetCreatedDateTime(cs3TimestampToTime(cTime))
|
||||
}
|
||||
|
||||
if expiration := receivedShare.GetExpiration(); expiration != nil {
|
||||
permission.SetExpirationDateTime(cs3TimestampToTime(expiration))
|
||||
}
|
||||
|
||||
var permissions *storageprovider.ResourcePermissions
|
||||
for _, protocol := range receivedShare.GetProtocols() {
|
||||
if protocol.GetWebdavOptions().GetPermissions() != nil {
|
||||
permissions = protocol.GetWebdavOptions().GetPermissions().GetPermissions()
|
||||
}
|
||||
}
|
||||
condition, err := federatedRoleConditionForResourceType(resourceInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
role := unifiedrole.CS3ResourcePermissionsToRole(
|
||||
availableRoles,
|
||||
permissions,
|
||||
condition,
|
||||
true,
|
||||
)
|
||||
if role != nil {
|
||||
permission.SetRoles([]string{role.GetId()})
|
||||
} else {
|
||||
actions := unifiedrole.CS3ResourcePermissionsToLibregraphActions(permissions)
|
||||
permission.SetLibreGraphPermissionsActions(actions)
|
||||
permission.SetRoles(nil)
|
||||
}
|
||||
|
||||
switch grantee := receivedShare.GetGrantee(); {
|
||||
case grantee.GetType() == storageprovider.GranteeType_GRANTEE_TYPE_USER:
|
||||
user, err := cs3UserIdToIdentity(ctx, identityCache, grantee.GetUserId())
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get user")
|
||||
return nil, err
|
||||
}
|
||||
permission.SetGrantedToV2(libregraph.SharePointIdentitySet{User: &user})
|
||||
case grantee.GetType() == storageprovider.GranteeType_GRANTEE_TYPE_GROUP:
|
||||
group, err := groupIdToIdentity(ctx, identityCache, grantee.GetGroupId().GetOpaqueId())
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get group")
|
||||
return nil, err
|
||||
}
|
||||
permission.SetGrantedToV2(libregraph.SharePointIdentitySet{Group: &group})
|
||||
}
|
||||
|
||||
return permission, nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
rConversions "github.com/opencloud-eu/reva/v2/pkg/conversions"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"google.golang.org/protobuf/testing/protocmp"
|
||||
|
||||
"github.com/qsfera/server/pkg/conversions"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/graph/pkg/identity/cache"
|
||||
service "github.com/qsfera/server/services/graph/pkg/service/v0"
|
||||
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
|
||||
)
|
||||
|
||||
var _ = Describe("Utils", func() {
|
||||
DescribeTable("GetDriveAndItemIDParam",
|
||||
func(driveID, itemID string, shouldPass bool) {
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("driveID", driveID)
|
||||
rctx.URLParams.Add("itemID", itemID)
|
||||
|
||||
extractedDriveID, extractedItemID, err := service.GetDriveAndItemIDParam(
|
||||
httptest.NewRequest(http.MethodGet, "/", nil).
|
||||
WithContext(
|
||||
context.WithValue(context.Background(), chi.RouteCtxKey, rctx),
|
||||
),
|
||||
conversions.ToPointer(log.NopLogger()),
|
||||
)
|
||||
|
||||
switch shouldPass {
|
||||
case true:
|
||||
Expect(err).To(BeNil())
|
||||
parsedItemID, _ := storagespace.ParseID(itemID)
|
||||
Expect(extractedItemID).To(BeComparableTo(&parsedItemID, protocmp.Transform()))
|
||||
|
||||
parsedDriveID, _ := storagespace.ParseID(driveID)
|
||||
Expect(extractedDriveID).To(BeComparableTo(&parsedDriveID, protocmp.Transform()))
|
||||
default:
|
||||
Expect(err).ToNot(BeNil())
|
||||
}
|
||||
},
|
||||
Entry("fails: invalid driveID", "", "1$2!3", false),
|
||||
Entry("fails: invalid itemID", "1$2", "", false),
|
||||
Entry("fails: incompatible driveID and itemID", "1$2", "3$4!5", false),
|
||||
Entry("fails: no itemID opaqueId", "1$2", "3$4", false),
|
||||
Entry("pass: valid driveID and itemID", "1$2", "1$2!5", true),
|
||||
)
|
||||
|
||||
DescribeTable("IsSpaceRoot",
|
||||
func(resourceID *provider.ResourceId, isRoot bool) {
|
||||
Expect(service.IsSpaceRoot(resourceID)).To(Equal(isRoot))
|
||||
},
|
||||
Entry("spaceId and opaqueID equal", &provider.ResourceId{
|
||||
StorageId: "1",
|
||||
OpaqueId: "2",
|
||||
SpaceId: "2",
|
||||
}, true),
|
||||
Entry("nil", nil, false),
|
||||
Entry("spaceID empty", &provider.ResourceId{
|
||||
StorageId: "1",
|
||||
OpaqueId: "2",
|
||||
}, false),
|
||||
Entry("opaqueID empty", &provider.ResourceId{
|
||||
StorageId: "1",
|
||||
SpaceId: "3",
|
||||
}, false),
|
||||
Entry("spaceID and opaqueID unequal", &provider.ResourceId{
|
||||
OpaqueId: "2",
|
||||
SpaceId: "3",
|
||||
}, false),
|
||||
)
|
||||
|
||||
DescribeTable("IsShareJail",
|
||||
func(resourceID *provider.ResourceId, isShareJail bool) {
|
||||
Expect(service.IsShareJail(resourceID)).To(Equal(isShareJail))
|
||||
},
|
||||
Entry("valid: share jail", &provider.ResourceId{
|
||||
StorageId: utils.ShareStorageProviderID,
|
||||
SpaceId: utils.ShareStorageSpaceID,
|
||||
}, true),
|
||||
Entry("invalid: empty storageId", &provider.ResourceId{
|
||||
SpaceId: utils.ShareStorageSpaceID,
|
||||
}, false),
|
||||
Entry("invalid: empty spaceId", &provider.ResourceId{
|
||||
StorageId: utils.ShareStorageProviderID,
|
||||
}, false),
|
||||
Entry("invalid: empty storageId and spaceId", &provider.ResourceId{}, false),
|
||||
Entry("invalid: non share jail storageId", &provider.ResourceId{
|
||||
StorageId: "123",
|
||||
SpaceId: utils.ShareStorageSpaceID,
|
||||
}, false),
|
||||
Entry("invalid: non share jail spaceId", &provider.ResourceId{
|
||||
StorageId: utils.ShareStorageProviderID,
|
||||
SpaceId: "123",
|
||||
}, false),
|
||||
Entry("invalid: non share jail storageID and spaceId", &provider.ResourceId{
|
||||
StorageId: "123",
|
||||
SpaceId: "123",
|
||||
}, false),
|
||||
)
|
||||
|
||||
DescribeTable("_cs3ReceivedShareToLibreGraphPermissions",
|
||||
func(permissionSet *provider.ResourcePermissions, match func(*libregraph.Permission)) {
|
||||
permission, err := service.CS3ReceivedShareToLibreGraphPermissions(
|
||||
context.Background(),
|
||||
nil,
|
||||
cache.IdentityCache{},
|
||||
&collaboration.ReceivedShare{
|
||||
Share: &collaboration.Share{
|
||||
Permissions: &collaboration.SharePermissions{
|
||||
Permissions: permissionSet,
|
||||
},
|
||||
},
|
||||
}, &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
},
|
||||
unifiedrole.GetRoles(unifiedrole.RoleFilterAll()),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
match(permission)
|
||||
},
|
||||
Entry(
|
||||
"permissions match a role",
|
||||
rConversions.NewViewerRole().CS3ResourcePermissions(),
|
||||
func(p *libregraph.Permission) {
|
||||
Expect(p.GetRoles()).To(HaveExactElements([]string{unifiedrole.UnifiedRoleViewerID}))
|
||||
Expect(p.GetLibreGraphPermissionsActions()).To(BeNil())
|
||||
},
|
||||
),
|
||||
Entry(
|
||||
"permissions do not match any role",
|
||||
&provider.ResourcePermissions{
|
||||
AddGrant: true,
|
||||
},
|
||||
func(p *libregraph.Permission) {
|
||||
Expect(p.GetRoles()).To(BeNil())
|
||||
Expect(p.GetLibreGraphPermissionsActions()).To(HaveExactElements([]string{unifiedrole.DriveItemPermissionsCreate}))
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user