refactor graph

Signed-off-by: Christian Richter <crichter@owncloud.com>
This commit is contained in:
Christian Richter
2022-04-13 17:04:37 +02:00
parent d4442941a1
commit 4d821cdc9d
59 changed files with 58 additions and 58 deletions
@@ -0,0 +1,253 @@
package svc
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"time"
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/cs3org/reva/v2/pkg/utils/resourceid"
"github.com/go-chi/render"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/extensions/graph/pkg/service/v0/errorcode"
)
// GetRootDriveChildren implements the Service interface.
func (g Graph) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) {
g.logger.Info().Msg("Calling GetRootDriveChildren")
ctx := r.Context()
client := g.GetGatewayClient()
res, err := client.GetHome(ctx, &storageprovider.GetHomeRequest{})
switch {
case err != nil:
g.logger.Error().Err(err).Msg("error sending get home grpc request")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.Status.Code != cs3rpc.Code_CODE_OK:
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.Status.Message)
return
}
g.logger.Error().Err(err).Msg("error sending get home grpc request")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
return
}
lRes, err := client.ListContainer(ctx, &storageprovider.ListContainerRequest{
Ref: &storageprovider.Reference{
Path: res.Path,
},
})
switch {
case err != nil:
g.logger.Error().Err(err).Msg("error sending list container grpc request")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.Status.Code != cs3rpc.Code_CODE_OK:
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.Status.Message)
return
}
if res.Status.Code == cs3rpc.Code_CODE_PERMISSION_DENIED {
// TODO check if we should return 404 to not disclose existing items
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, res.Status.Message)
return
}
g.logger.Error().Err(err).Msg("error sending list container grpc request")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
return
}
files, err := formatDriveItems(lRes.Infos)
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})
}
func (g Graph) getDriveItem(ctx context.Context, root *storageprovider.ResourceId) (*libregraph.DriveItem, error) {
client := g.GetGatewayClient()
ref := &storageprovider.Reference{
ResourceId: root,
}
res, err := client.Stat(ctx, &storageprovider.StatRequest{Ref: ref})
if err != nil {
return nil, err
}
if res.Status.Code != cs3rpc.Code_CODE_OK {
return nil, fmt.Errorf("could not stat %s: %s", ref, res.Status.Message)
}
return cs3ResourceToDriveItem(res.Info)
}
func (g Graph) getRemoteItem(ctx context.Context, root *storageprovider.ResourceId, baseURL *url.URL) (*libregraph.RemoteItem, error) {
client := g.GetGatewayClient()
ref := &storageprovider.Reference{
ResourceId: root,
}
res, err := client.Stat(ctx, &storageprovider.StatRequest{Ref: ref})
if err != nil {
return nil, err
}
if res.Status.Code != cs3rpc.Code_CODE_OK {
// Only log this, there could be mountpoints which have no grant
g.logger.Debug().Msg(res.Status.Message)
return nil, errors.New("could not fetch grant resource for the mountpoint")
}
item, err := cs3ResourceToRemoteItem(res.Info)
if err != nil {
return nil, err
}
item.WebDavUrl = libregraph.PtrString(baseURL.String() + resourceid.OwnCloudResourceIDWrap(root))
return item, nil
}
func formatDriveItems(mds []*storageprovider.ResourceInfo) ([]*libregraph.DriveItem, error) {
responses := make([]*libregraph.DriveItem, 0, len(mds))
for i := range mds {
res, err := cs3ResourceToDriveItem(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.Seconds), int64(t.Nanos))
}
func cs3ResourceToDriveItem(res *storageprovider.ResourceInfo) (*libregraph.DriveItem, error) {
size := new(int64)
*size = int64(res.Size) // TODO lurking overflow: make size of libregraph drive item use uint64
driveItem := &libregraph.DriveItem{
Id: libregraph.PtrString(resourceid.OwnCloudResourceIDWrap(res.Id)),
Size: size,
}
if name := path.Base(res.Path); name != "" {
driveItem.Name = &name
}
if res.Etag != "" {
driveItem.ETag = &res.Etag
}
if res.Mtime != nil {
lastModified := cs3TimestampToTime(res.Mtime)
driveItem.LastModifiedDateTime = &lastModified
}
if res.Type == storageprovider.ResourceType_RESOURCE_TYPE_FILE && res.MimeType != "" {
// We cannot use a libregraph.File here because the openapi codegenerator autodetects 'File' as a go type ...
driveItem.File = &libregraph.OpenGraphFile{
MimeType: &res.MimeType,
}
}
if res.Type == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER {
driveItem.Folder = &libregraph.Folder{}
}
return driveItem, nil
}
func cs3ResourceToRemoteItem(res *storageprovider.ResourceInfo) (*libregraph.RemoteItem, error) {
size := new(int64)
*size = int64(res.Size) // TODO lurking overflow: make size of libregraph drive item use uint64
remoteItem := &libregraph.RemoteItem{
Id: libregraph.PtrString(resourceid.OwnCloudResourceIDWrap(res.Id)),
Size: size,
}
if name := path.Base(res.Path); name != "" {
remoteItem.Name = &name
}
if res.Etag != "" {
remoteItem.ETag = &res.Etag
}
if res.Mtime != nil {
lastModified := cs3TimestampToTime(res.Mtime)
remoteItem.LastModifiedDateTime = &lastModified
}
if res.Type == storageprovider.ResourceType_RESOURCE_TYPE_FILE && res.MimeType != "" {
// 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.Type == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER {
remoteItem.Folder = &libregraph.Folder{}
}
return remoteItem, nil
}
func (g Graph) getPathForResource(ctx context.Context, ID *storageprovider.ResourceId) (*string, error) {
client := g.GetGatewayClient()
var path *string
res, err := client.GetPath(ctx, &storageprovider.GetPathRequest{ResourceId: ID})
if err != nil {
return nil, err
}
if res.Status.Code != cs3rpc.Code_CODE_OK {
return nil, fmt.Errorf("could not stat %s: %s", ID, res.Status.Message)
}
path = &res.Path
return path, err
}
// GetExtendedSpaceProperties reads properties from the opaque and transforms them into driveItems
func (g Graph) GetExtendedSpaceProperties(ctx context.Context, baseURL *url.URL, space *storageprovider.StorageSpace) []libregraph.DriveItem {
var spaceItems []libregraph.DriveItem
if space.Opaque == nil {
return nil
}
metadata := space.Opaque.Map
names := [2]string{SpaceImageSpecialFolderName, ReadmeSpecialFolderName}
for _, itemName := range names {
if itemID, ok := metadata[itemName]; ok {
spaceItem := g.getSpecialDriveItem(ctx, resourceid.OwnCloudResourceIDUnwrap(string(itemID.Value)), itemName, baseURL, space)
if spaceItem != nil {
spaceItems = append(spaceItems, *spaceItem)
}
}
}
return spaceItems
}
func (g Graph) getSpecialDriveItem(ctx context.Context, ID *storageprovider.ResourceId, itemName string, baseURL *url.URL, space *storageprovider.StorageSpace) *libregraph.DriveItem {
var spaceItem *libregraph.DriveItem
if ID == nil {
return nil
}
spaceItem, err := g.getDriveItem(ctx, ID)
if err != nil {
g.logger.Error().Err(err).Str("ID", ID.OpaqueId).Msg("Could not get readme Item")
return nil
}
itemPath, err := g.getPathForResource(ctx, ID)
if err != nil {
g.logger.Error().Err(err).Str("ID", ID.OpaqueId).Msg("Could not get readme path")
return nil
}
spaceItem.SpecialFolder = &libregraph.SpecialFolder{Name: libregraph.PtrString(itemName)}
spaceItem.WebDavUrl = libregraph.PtrString(baseURL.String() + path.Join(space.Id.OpaqueId, *itemPath))
return spaceItem
}
+799
View File
@@ -0,0 +1,799 @@
package svc
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"github.com/CiscoM31/godata"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/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"
ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
"github.com/cs3org/reva/v2/pkg/utils"
"github.com/cs3org/reva/v2/pkg/utils/resourceid"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/extensions/graph/pkg/service/v0/errorcode"
"github.com/owncloud/ocis/ocis-pkg/service/grpc"
v0 "github.com/owncloud/ocis/protogen/gen/ocis/messages/settings/v0"
settingssvc "github.com/owncloud/ocis/protogen/gen/ocis/services/settings/v0"
settingsServiceExt "github.com/owncloud/ocis/settings/pkg/service/v0"
merrors "go-micro.dev/v4/errors"
)
// GetDrives implements the Service interface.
func (g Graph) GetDrives(w http.ResponseWriter, r *http.Request) {
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
// Parse the request with odata parser
odataReq, 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
}
g.logger.Info().Interface("query", r.URL.Query()).Msg("Calling GetDrives")
ctx := r.Context()
filters, err := generateCs3Filters(odataReq)
if err != nil {
g.logger.Err(err).Interface("query", r.URL.Query()).Msg("query error")
errorcode.NotSupported.Render(w, r, http.StatusNotImplemented, err.Error())
return
}
res, err := g.ListStorageSpacesWithFilters(ctx, filters)
switch {
case err != nil:
g.logger.Error().Err(err).Msg(ListStorageSpacesTransportErr)
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.Status.Code != cs3rpc.Code_CODE_OK:
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
// return an empty list
render.Status(r, http.StatusOK)
render.JSON(w, r, &listResponse{})
return
}
g.logger.Error().Err(err).Msg(ListStorageSpacesReturnsErr)
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
return
}
wdu, err := url.Parse(g.config.Spaces.WebDavBase + g.config.Spaces.WebDavPath)
if err != nil {
g.logger.Error().Err(err).Msg("error parsing url")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
spaces, err := g.formatDrives(ctx, wdu, res.StorageSpaces)
if err != nil {
g.logger.Error().Err(err).Msg("error encoding response as json")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
spaces, err = sortSpaces(odataReq, spaces)
if err != nil {
g.logger.Error().Err(err).Msg("error sorting the spaces list")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &listResponse{Value: spaces})
}
// GetSingleDrive does a lookup of a single space by spaceId
func (g Graph) GetSingleDrive(w http.ResponseWriter, r *http.Request) {
driveID := chi.URLParam(r, "driveID")
if driveID == "" {
err := fmt.Errorf("no valid space id retrieved")
g.logger.Err(err)
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
g.logger.Info().Str("driveID", driveID).Msg("Calling GetSingleDrive")
ctx := r.Context()
filters := []*storageprovider.ListStorageSpacesRequest_Filter{listStorageSpacesIDFilter(driveID)}
res, err := g.ListStorageSpacesWithFilters(ctx, filters)
switch {
case err != nil:
g.logger.Error().Err(err).Msg(ListStorageSpacesTransportErr)
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.Status.Code != cs3rpc.Code_CODE_OK:
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
// the client is doing a lookup for a specific space, therefore we need to return
// not found to the caller
g.logger.Error().Str("driveID", driveID).Msg(fmt.Sprintf(NoSpaceFoundMessage, driveID))
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, fmt.Sprintf(NoSpaceFoundMessage, driveID))
return
}
g.logger.Error().Err(err).Msg(ListStorageSpacesReturnsErr)
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
return
}
wdu, err := url.Parse(g.config.Spaces.WebDavBase + g.config.Spaces.WebDavPath)
if err != nil {
g.logger.Error().Err(err).Msg("error parsing url")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
spaces, err := g.formatDrives(ctx, wdu, res.StorageSpaces)
if err != nil {
g.logger.Error().Err(err).Msg("error encoding response")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
switch num := len(spaces); {
case num == 0:
g.logger.Error().Str("driveID", driveID).Msg("no space found")
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, fmt.Sprintf(NoSpaceFoundMessage, driveID))
return
case num == 1:
render.Status(r, http.StatusOK)
render.JSON(w, r, spaces[0])
default:
g.logger.Error().Int("number", num).Msg("expected to find a single space but found more")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "expected to find a single space but found more")
return
}
}
func canCreateSpace(ctx context.Context, ownPersonalHome bool) bool {
s := settingssvc.NewPermissionService("com.owncloud.api.settings", grpc.DefaultClient)
pr, err := s.GetPermissionByID(ctx, &settingssvc.GetPermissionByIDRequest{
PermissionId: settingsServiceExt.CreateSpacePermissionID,
})
if err != nil || pr.Permission == nil {
return false
}
// TODO @C0rby shouldn't the permissions service check this? aka shouldn't we call CheckPermission?
if pr.Permission.Constraint == v0.Permission_CONSTRAINT_OWN && !ownPersonalHome {
return false
}
return true
}
// CreateDrive creates a storage drive (space).
func (g Graph) CreateDrive(w http.ResponseWriter, r *http.Request) {
us, ok := ctxpkg.ContextGetUser(r.Context())
if !ok {
errorcode.GeneralException.Render(w, r, http.StatusUnauthorized, "invalid user")
return
}
// TODO determine if the user tries to create his own personal space and pass that as a boolean
if !canCreateSpace(r.Context(), false) {
// if the permission is not existing for the user in context we can assume we don't have it. Return 401.
errorcode.GeneralException.Render(w, r, http.StatusUnauthorized, "insufficient permissions to create a space.")
return
}
client := g.GetGatewayClient()
drive := libregraph.Drive{}
if err := json.NewDecoder(r.Body).Decode(&drive); err != nil {
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, "invalid schema definition")
return
}
spaceName := *drive.Name
if spaceName == "" {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "invalid name")
return
}
var driveType string
if drive.DriveType != nil {
driveType = *drive.DriveType
}
switch driveType {
case "", "project":
driveType = "project"
default:
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, fmt.Sprintf("drives of type %s cannot be created via this api", driveType))
return
}
csr := storageprovider.CreateStorageSpaceRequest{
Owner: us,
Type: driveType,
Name: spaceName,
Quota: getQuota(drive.Quota, g.config.Spaces.DefaultQuota),
}
if drive.Description != nil {
csr.Opaque = utils.AppendPlainToOpaque(csr.Opaque, "description", *drive.Description)
}
if drive.DriveAlias != nil {
csr.Opaque = utils.AppendPlainToOpaque(csr.Opaque, "spaceAlias", *drive.DriveAlias)
}
resp, err := client.CreateStorageSpace(r.Context(), &csr)
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
if resp.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "")
return
}
wdu, err := url.Parse(g.config.Spaces.WebDavBase + g.config.Spaces.WebDavPath)
if err != nil {
g.logger.Error().Err(err).Msg("error parsing url")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
newDrive, err := g.cs3StorageSpaceToDrive(r.Context(), wdu, resp.StorageSpace)
if err != nil {
g.logger.Error().Err(err).Msg("error parsing space")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.Status(r, http.StatusCreated)
render.JSON(w, r, newDrive)
}
func (g Graph) UpdateDrive(w http.ResponseWriter, r *http.Request) {
driveID, err := url.PathUnescape(chi.URLParam(r, "driveID"))
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping drive id failed")
return
}
if driveID == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing drive id")
return
}
drive := libregraph.Drive{}
if err = json.NewDecoder(r.Body).Decode(&drive); err != nil {
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", r.Body))
return
}
root := &storageprovider.ResourceId{}
identifierParts := strings.Split(driveID, "!")
switch len(identifierParts) {
case 1:
root.StorageId, root.OpaqueId = identifierParts[0], identifierParts[0]
case 2:
root.StorageId, root.OpaqueId = identifierParts[0], identifierParts[1]
default:
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid resource id: %v", driveID))
w.WriteHeader(http.StatusInternalServerError)
return
}
client := g.GetGatewayClient()
updateSpaceRequest := &storageprovider.UpdateStorageSpaceRequest{
// Prepare the object to apply the diff from. The properties on StorageSpace will overwrite
// the original storage space.
StorageSpace: &storageprovider.StorageSpace{
Id: &storageprovider.StorageSpaceId{
OpaqueId: root.StorageId + "!" + root.OpaqueId,
},
Root: root,
},
}
// Note: this is the Opaque prop of the request
if restore, _ := strconv.ParseBool(r.Header.Get("restore")); restore {
updateSpaceRequest.Opaque = &types.Opaque{
Map: map[string]*types.OpaqueEntry{
"restore": {
Decoder: "plain",
Value: []byte("true"),
},
},
}
}
if drive.Description != nil {
updateSpaceRequest.StorageSpace.Opaque = utils.AppendPlainToOpaque(updateSpaceRequest.StorageSpace.Opaque, "description", *drive.Description)
}
if drive.DriveAlias != nil {
updateSpaceRequest.StorageSpace.Opaque = utils.AppendPlainToOpaque(updateSpaceRequest.StorageSpace.Opaque, "spaceAlias", *drive.DriveAlias)
}
for _, special := range drive.Special {
if special.Id != nil {
updateSpaceRequest.StorageSpace.Opaque = utils.AppendPlainToOpaque(updateSpaceRequest.StorageSpace.Opaque, *special.SpecialFolder.Name, *special.Id)
}
}
if drive.Name != nil {
updateSpaceRequest.StorageSpace.Name = *drive.Name
}
if drive.Quota.HasTotal() {
user := ctxpkg.ContextMustGetUser(r.Context())
canSetSpaceQuota, err := canSetSpaceQuota(r.Context(), user)
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
if !canSetSpaceQuota {
errorcode.GeneralException.Render(w, r, http.StatusUnauthorized, "user is not allowed to set the space quota")
return
}
updateSpaceRequest.StorageSpace.Quota = &storageprovider.Quota{
QuotaMaxBytes: uint64(*drive.Quota.Total),
}
}
resp, err := client.UpdateStorageSpace(r.Context(), updateSpaceRequest)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if resp.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
switch resp.Status.GetCode() {
case cs3rpc.Code_CODE_NOT_FOUND:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, resp.GetStatus().GetMessage())
return
case cs3rpc.Code_CODE_PERMISSION_DENIED:
errorcode.NotAllowed.Render(w, r, http.StatusForbidden, resp.GetStatus().GetMessage())
return
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, resp.GetStatus().GetMessage())
return
}
}
wdu, err := url.Parse(g.config.Spaces.WebDavBase + g.config.Spaces.WebDavPath)
if err != nil {
g.logger.Error().Err(err).Msg("error parsing url")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
spaces, err := g.formatDrives(r.Context(), wdu, []*storageprovider.StorageSpace{resp.StorageSpace})
if err != nil {
g.logger.Error().Err(err).Msg("error parsing space")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, spaces[0])
}
func (g Graph) formatDrives(ctx context.Context, baseURL *url.URL, storageSpaces []*storageprovider.StorageSpace) ([]*libregraph.Drive, error) {
responses := make([]*libregraph.Drive, 0, len(storageSpaces))
for _, storageSpace := range storageSpaces {
res, err := g.cs3StorageSpaceToDrive(ctx, baseURL, storageSpace)
if err != nil {
return nil, err
}
res.Special = g.GetExtendedSpaceProperties(ctx, baseURL, storageSpace)
res.Quota, err = g.getDriveQuota(ctx, storageSpace)
if err != nil {
return nil, err
}
responses = append(responses, res)
}
return responses, nil
}
// ListStorageSpacesWithFilters List Storage Spaces using filters
func (g Graph) ListStorageSpacesWithFilters(ctx context.Context, filters []*storageprovider.ListStorageSpacesRequest_Filter) (*storageprovider.ListStorageSpacesResponse, error) {
client := g.GetGatewayClient()
permissions := make(map[string]struct{}, 1)
s := settingssvc.NewPermissionService("com.owncloud.api.settings", grpc.DefaultClient)
_, err := s.GetPermissionByID(ctx, &settingssvc.GetPermissionByIDRequest{
PermissionId: settingsServiceExt.ListAllSpacesPermissionID,
})
// No error means the user has the permission
if err == nil {
permissions[settingsServiceExt.ListAllSpacesPermissionName] = struct{}{}
}
value, err := json.Marshal(permissions)
if err != nil {
return nil, err
}
res, err := client.ListStorageSpaces(ctx, &storageprovider.ListStorageSpacesRequest{
Opaque: &types.Opaque{Map: map[string]*types.OpaqueEntry{
"permissions": {
Decoder: "json",
Value: value,
},
}},
Filters: filters,
})
return res, err
}
func (g Graph) cs3StorageSpaceToDrive(ctx context.Context, baseURL *url.URL, space *storageprovider.StorageSpace) (*libregraph.Drive, error) {
if space.Root == nil {
return nil, fmt.Errorf("space has no root")
}
rootID := resourceid.OwnCloudResourceIDWrap(space.Root)
var permissions []libregraph.Permission
if space.Opaque != nil {
var m map[string]*storageprovider.ResourcePermissions
entry, ok := space.Opaque.Map["grants"]
if ok {
err := json.Unmarshal(entry.Value, &m)
if err != nil {
g.logger.Error().
Err(err).
Str("space", space.Root.OpaqueId).
Msg("failed to read spaces grants")
}
}
if len(m) != 0 {
managerIdentities := []libregraph.IdentitySet{}
editorIdentities := []libregraph.IdentitySet{}
viewerIdentities := []libregraph.IdentitySet{}
for id, perm := range m {
// This temporary variable is necessary since we need to pass a pointer to the
// libregraph.Identity and if we pass the pointer from the loop every identity
// will have the same id.
tmp := id
identity := libregraph.IdentitySet{User: &libregraph.Identity{Id: &tmp}}
switch {
case perm.AddGrant:
managerIdentities = append(managerIdentities, identity)
case perm.InitiateFileUpload:
editorIdentities = append(editorIdentities, identity)
case perm.Stat:
viewerIdentities = append(viewerIdentities, identity)
}
}
permissions = make([]libregraph.Permission, 0, 3)
if len(managerIdentities) != 0 {
permissions = append(permissions, libregraph.Permission{
GrantedTo: managerIdentities,
Roles: []string{"manager"},
})
}
if len(editorIdentities) != 0 {
permissions = append(permissions, libregraph.Permission{
GrantedTo: editorIdentities,
Roles: []string{"editor"},
})
}
if len(viewerIdentities) != 0 {
permissions = append(permissions, libregraph.Permission{
GrantedTo: viewerIdentities,
Roles: []string{"viewer"},
})
}
}
}
spaceID := space.Root.StorageId
if space.Root.OpaqueId != space.Root.StorageId {
spaceID = rootID
}
drive := &libregraph.Drive{
Id: &spaceID,
Name: &space.Name,
//"createdDateTime": "string (timestamp)", // TODO read from StorageSpace ... needs Opaque for now
//"description": "string", // TODO read from StorageSpace ... needs Opaque for now
DriveType: &space.SpaceType,
Root: &libregraph.DriveItem{
Id: &rootID,
Permissions: permissions,
},
}
if space.SpaceType == "mountpoint" {
var remoteItem *libregraph.RemoteItem
grantID := storageprovider.ResourceId{
StorageId: utils.ReadPlainFromOpaque(space.Opaque, "grantStorageID"),
OpaqueId: utils.ReadPlainFromOpaque(space.Opaque, "grantOpaqueID"),
}
if grantID.StorageId != "" && grantID.OpaqueId != "" {
var err error
remoteItem, err = g.getRemoteItem(ctx, &grantID, baseURL)
if err != nil {
g.logger.Debug().Err(err).Msg(err.Error())
}
}
if remoteItem != nil {
drive.Root.RemoteItem = remoteItem
}
}
if space.Opaque != nil {
if description, ok := space.Opaque.Map["description"]; ok {
drive.Description = libregraph.PtrString(string(description.Value))
}
if alias, ok := space.Opaque.Map["spaceAlias"]; ok {
drive.DriveAlias = libregraph.PtrString(string(alias.Value))
}
if v, ok := space.Opaque.Map["trashed"]; ok {
deleted := &libregraph.Deleted{}
deleted.SetState(string(v.Value))
drive.Root.Deleted = deleted
}
if entry, ok := space.Opaque.Map["etag"]; ok {
drive.Root.ETag = libregraph.PtrString(string(entry.Value))
}
}
if baseURL != nil {
// TODO read from StorageSpace ... needs Opaque for now
// TODO how do we build the url?
// for now: read from request
webDavURL := baseURL.String() + spaceID
drive.Root.WebDavUrl = &webDavURL
}
// TODO The public space has no owner ... should we even show it?
if space.Owner != nil && space.Owner.Id != nil {
drive.Owner = &libregraph.IdentitySet{
User: &libregraph.Identity{
Id: &space.Owner.Id.OpaqueId,
// DisplayName: , TODO read and cache from users provider
},
}
}
if space.Mtime != nil {
lastModified := cs3TimestampToTime(space.Mtime)
drive.LastModifiedDateTime = &lastModified
}
if space.Quota != nil {
var t int64
if space.Quota.QuotaMaxBytes > math.MaxInt64 {
t = math.MaxInt64
} else {
t = int64(space.Quota.QuotaMaxBytes)
}
drive.Quota = &libregraph.Quota{
Total: &t,
}
}
// FIXME use coowner from https://github.com/owncloud/open-graph-api
return drive, nil
}
func (g Graph) getDriveQuota(ctx context.Context, space *storageprovider.StorageSpace) (*libregraph.Quota, error) {
client := g.GetGatewayClient()
req := &gateway.GetQuotaRequest{
Ref: &storageprovider.Reference{
ResourceId: &storageprovider.ResourceId{
StorageId: space.Root.StorageId,
OpaqueId: space.Root.OpaqueId,
},
Path: ".",
},
}
res, err := client.GetQuota(ctx, req)
switch {
case err != nil:
g.logger.Error().Err(err).Msg("could not call GetQuota")
return nil, nil
case res.Status.Code == cs3rpc.Code_CODE_UNIMPLEMENTED:
// TODO well duh
return nil, nil
case res.Status.Code != cs3rpc.Code_CODE_OK:
g.logger.Error().Err(err).Msg("error sending get quota grpc request")
return nil, err
}
var remaining int64
if res.Opaque != nil {
m := res.Opaque.Map
if e, ok := m["remaining"]; ok {
remaining, _ = strconv.ParseInt(string(e.Value), 10, 64)
}
}
used := int64(res.UsedBytes)
qta := libregraph.Quota{
Remaining: &remaining,
Used: &used,
}
var t int64
if total := int64(res.TotalBytes); total != 0 {
// A quota was set
qta.Total = &total
t = total
} else {
// Quota was not set
// Use remaining bytes to calculate state
t = remaining
}
state := calculateQuotaState(t, used)
qta.State = &state
return &qta, nil
}
func calculateQuotaState(total int64, used int64) (state string) {
percent := (float64(used) / float64(total)) * 100
switch {
case percent <= float64(75):
return "normal"
case percent <= float64(90):
return "nearing"
case percent <= float64(99):
return "critical"
default:
return "exceeded"
}
}
func getQuota(quota *libregraph.Quota, defaultQuota string) *storageprovider.Quota {
switch {
case quota != nil && quota.Total != nil:
if q := *quota.Total; q >= 0 {
return &storageprovider.Quota{QuotaMaxBytes: uint64(q)}
}
fallthrough
case defaultQuota != "":
if q, err := strconv.ParseInt(defaultQuota, 10, 64); err == nil && q >= 0 {
return &storageprovider.Quota{QuotaMaxBytes: uint64(q)}
}
fallthrough
default:
return nil
}
}
func canSetSpaceQuota(ctx context.Context, user *userv1beta1.User) (bool, error) {
settingsService := settingssvc.NewPermissionService("com.owncloud.api.settings", grpc.DefaultClient)
_, err := settingsService.GetPermissionByID(ctx, &settingssvc.GetPermissionByIDRequest{PermissionId: settingsServiceExt.SetSpaceQuotaPermissionID})
if err != nil {
merror := merrors.FromError(err)
if merror.Status == http.StatusText(http.StatusNotFound) {
return false, nil
}
return false, err
}
return true, nil
}
func generateCs3Filters(request *godata.GoDataRequest) ([]*storageprovider.ListStorageSpacesRequest_Filter, error) {
var filters []*storageprovider.ListStorageSpacesRequest_Filter
if request.Query.Filter != nil {
if request.Query.Filter.Tree.Token.Value == "eq" {
switch request.Query.Filter.Tree.Children[0].Token.Value {
case "driveType":
filters = append(filters, listStorageSpacesTypeFilter(strings.Trim(request.Query.Filter.Tree.Children[1].Token.Value, "'")))
case "id":
filters = append(filters, listStorageSpacesIDFilter(strings.Trim(request.Query.Filter.Tree.Children[1].Token.Value, "'")))
}
} else {
err := fmt.Errorf("unsupported filter operand: %s", request.Query.Filter.Tree.Token.Value)
return nil, err
}
}
return filters, nil
}
func listStorageSpacesIDFilter(id string) *storageprovider.ListStorageSpacesRequest_Filter {
return &storageprovider.ListStorageSpacesRequest_Filter{
Type: storageprovider.ListStorageSpacesRequest_Filter_TYPE_ID,
Term: &storageprovider.ListStorageSpacesRequest_Filter_Id{
Id: &storageprovider.StorageSpaceId{
OpaqueId: id,
},
},
}
}
func listStorageSpacesTypeFilter(spaceType string) *storageprovider.ListStorageSpacesRequest_Filter {
return &storageprovider.ListStorageSpacesRequest_Filter{
Type: storageprovider.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE,
Term: &storageprovider.ListStorageSpacesRequest_Filter_SpaceType{
SpaceType: spaceType,
},
}
}
func (g Graph) DeleteDrive(w http.ResponseWriter, r *http.Request) {
driveID, err := url.PathUnescape(chi.URLParam(r, "driveID"))
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping drive id failed")
return
}
if driveID == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing drive id")
return
}
root := &storageprovider.ResourceId{}
identifierParts := strings.Split(driveID, "!")
switch len(identifierParts) {
case 1:
root.StorageId, root.OpaqueId = identifierParts[0], identifierParts[0]
case 2:
root.StorageId, root.OpaqueId = identifierParts[0], identifierParts[1]
default:
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid resource id: %v", driveID))
w.WriteHeader(http.StatusInternalServerError)
return
}
purge := parsePurgeHeader(r.Header)
var opaque *types.Opaque
if purge {
opaque = &types.Opaque{
Map: map[string]*types.OpaqueEntry{
"purge": {},
},
}
}
dRes, err := g.gatewayClient.DeleteStorageSpace(r.Context(), &storageprovider.DeleteStorageSpaceRequest{
Opaque: opaque,
Id: &storageprovider.StorageSpaceId{
OpaqueId: root.StorageId,
},
})
switch {
case dRes.Status.Code == cs3rpc.Code_CODE_INVALID_ARGUMENT:
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, dRes.Status.Message)
w.WriteHeader(http.StatusBadRequest)
return
case err != nil || dRes.Status.Code != cs3rpc.Code_CODE_OK:
g.logger.Error().Err(err).Msg("error deleting storage space")
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func sortSpaces(req *godata.GoDataRequest, spaces []*libregraph.Drive) ([]*libregraph.Drive, error) {
var sorter sort.Interface
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
return spaces, nil
}
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
case "name":
sorter = spacesByName{spaces}
case "lastModifiedDateTime":
sorter = spacesByLastModifiedDateTime{spaces}
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 == "desc" {
sorter = sort.Reverse(sorter)
}
sort.Sort(sorter)
return spaces, nil
}
@@ -0,0 +1,123 @@
package svc
import (
"testing"
"time"
"github.com/CiscoM31/godata"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/stretchr/testify/assert"
)
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", "Einstein", &time1),
drive("2", "project", "Marie", &time2),
drive("4", "project", "Richard", &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", "Einstein", &time1),
drive("2", "project", "Marie", &time2),
drive("4", "project", "Richard", &time4),
},
},
{
Drives: drives,
Query: godata.GoDataRequest{
Query: &godata.GoDataQuery{
OrderBy: &godata.GoDataOrderByQuery{
OrderByItems: []*godata.OrderByItem{
{Field: &godata.Token{Value: "name"}, Order: "desc"},
},
},
},
},
DrivesSorted: []*libregraph.Drive{
drive("4", "project", "Richard", &time4),
drive("2", "project", "Marie", &time2),
drive("1", "project", "Einstein", &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", "Einstein", &time1),
drive("2", "project", "Marie", &time2),
drive("4", "project", "Richard", &time4),
},
},
{
Drives: drivesLong,
Query: godata.GoDataRequest{
Query: &godata.GoDataQuery{
OrderBy: &godata.GoDataOrderByQuery{
OrderByItems: []*godata.OrderByItem{
{Field: &godata.Token{Value: "lastModifiedDateTime"}, Order: "desc"},
},
},
},
},
DrivesSorted: []*libregraph.Drive{
drive("4", "project", "Richard", &time4),
drive("2", "project", "Marie", &time2),
drive("1", "project", "Einstein", &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: libregraph.PtrString(name), LastModifiedDateTime: lastModified}
}
// 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)
}
}
@@ -0,0 +1,114 @@
package errorcode
import (
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
libregraph "github.com/owncloud/libre-graph-api-go"
)
// ErrorCode defines code as used in MS Graph - see https://docs.microsoft.com/en-us/graph/errors?context=graph%2Fapi%2F1.0&view=graph-rest-1.0
type ErrorCode int
type Error struct {
errorCode ErrorCode
msg string
}
const (
// AccessDenied defines the error if the caller doesn't have permission to perform the action.
AccessDenied ErrorCode = iota
// ActivityLimitReached defines the error if the app or user has been throttled.
ActivityLimitReached
// GeneralException defines the error if an unspecified error has occurred.
GeneralException
// InvalidAuthenticationToken defines the error if the access token is missing
InvalidAuthenticationToken
// InvalidRange defines the error if the specified byte range is invalid or unavailable.
InvalidRange
// InvalidRequest defines the error if the request is malformed or incorrect.
InvalidRequest
// ItemNotFound defines the error if the resource could not be found.
ItemNotFound
// MalwareDetected defines the error if malware was detected in the requested resource.
MalwareDetected
// NameAlreadyExists defines the error if the specified item name already exists.
NameAlreadyExists
// NotAllowed defines the error if the action is not allowed by the system.
NotAllowed
// NotSupported defines the error if the request is not supported by the system.
NotSupported
// ResourceModified defines the error if the resource being updated has changed since the caller last read it, usually an eTag mismatch.
ResourceModified
// ResyncRequired defines the error if the delta token is no longer valid, and the app must reset the sync state.
ResyncRequired
// ServiceNotAvailable defines the error if the service is not available. Try the request again after a delay. There may be a Retry-After header.
ServiceNotAvailable
// QuotaLimitReached the user has reached their quota limit.
QuotaLimitReached
// Unauthenticated the caller is not authenticated.
Unauthenticated
)
var errorCodes = [...]string{
"accessDenied",
"activityLimitReached",
"generalException",
"InvalidAuthenticationToken",
"invalidRange",
"invalidRequest",
"itemNotFound",
"malwareDetected",
"nameAlreadyExists",
"notAllowed",
"notSupported",
"resourceModified",
"resyncRequired",
"serviceNotAvailable",
"quotaLimitReached",
"unauthenticated",
}
func New(e ErrorCode, msg string) Error {
return Error{
errorCode: e,
msg: msg,
}
}
// Render writes an Graph ErrorObject to the response writer
func (e ErrorCode) Render(w http.ResponseWriter, r *http.Request, status int, msg string) {
innererror := map[string]interface{}{
"date": time.Now().UTC().Format(time.RFC3339),
// TODO return client-request-id?
}
innererror["request-id"] = middleware.GetReqID(r.Context())
resp := &libregraph.OdataError{
Error: libregraph.OdataErrorMain{
Code: e.String(),
Message: msg,
Innererror: innererror,
},
}
render.Status(r, status)
render.JSON(w, r, resp)
}
func (e Error) Render(w http.ResponseWriter, r *http.Request) {
status := http.StatusInternalServerError
if e.errorCode == ItemNotFound {
status = http.StatusNotFound
}
e.errorCode.Render(w, r, status, e.msg)
}
func (e ErrorCode) String() string {
return errorCodes[e]
}
func (e Error) Error() string {
return errorCodes[e.errorCode]
}
+115
View File
@@ -0,0 +1,115 @@
package svc
import (
"context"
"net/http"
"github.com/ReneKroon/ttlcache/v2"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/cs3org/reva/v2/pkg/events"
"github.com/go-chi/chi/v5"
"github.com/owncloud/ocis/extensions/graph/pkg/config"
"github.com/owncloud/ocis/extensions/graph/pkg/identity"
"github.com/owncloud/ocis/ocis-pkg/log"
settingssvc "github.com/owncloud/ocis/protogen/gen/ocis/services/settings/v0"
mevents "go-micro.dev/v4/events"
"google.golang.org/grpc"
)
//go:generate make generate
// GatewayClient is the subset of the gateway.GatewayAPIClient that is being used to interact with the gateway
type GatewayClient interface {
//gateway.GatewayAPIClient
// Returns the home path for the given authenticated user.
// When a user has access to multiple storage providers, one of them is the home.
GetHome(ctx context.Context, in *provider.GetHomeRequest, opts ...grpc.CallOption) (*provider.GetHomeResponse, error)
// GetPath does a path lookup for a resource by ID
GetPath(ctx context.Context, in *provider.GetPathRequest, opts ...grpc.CallOption) (*provider.GetPathResponse, error)
// Returns a list of resource information
// for the provided reference.
// MUST return CODE_NOT_FOUND if the reference does not exists.
ListContainer(ctx context.Context, in *provider.ListContainerRequest, opts ...grpc.CallOption) (*provider.ListContainerResponse, error)
// Returns the resource information at the provided reference.
// MUST return CODE_NOT_FOUND if the reference does not exist.
Stat(ctx context.Context, in *provider.StatRequest, opts ...grpc.CallOption) (*provider.StatResponse, error)
// Initiates the download of a file using an
// out-of-band data transfer mechanism.
InitiateFileDownload(ctx context.Context, in *provider.InitiateFileDownloadRequest, opts ...grpc.CallOption) (*gateway.InitiateFileDownloadResponse, error)
// Creates a storage space.
CreateStorageSpace(ctx context.Context, in *provider.CreateStorageSpaceRequest, opts ...grpc.CallOption) (*provider.CreateStorageSpaceResponse, error)
// Lists storage spaces.
ListStorageSpaces(ctx context.Context, in *provider.ListStorageSpacesRequest, opts ...grpc.CallOption) (*provider.ListStorageSpacesResponse, error)
// Updates a storage space.
UpdateStorageSpace(ctx context.Context, in *provider.UpdateStorageSpaceRequest, opts ...grpc.CallOption) (*provider.UpdateStorageSpaceResponse, error)
// Deletes a storage space.
DeleteStorageSpace(ctx context.Context, in *provider.DeleteStorageSpaceRequest, opts ...grpc.CallOption) (*provider.DeleteStorageSpaceResponse, error)
// Returns the quota available under the provided
// reference.
// MUST return CODE_NOT_FOUND if the reference does not exist
// MUST return CODE_RESOURCE_EXHAUSTED on exceeded quota limits.
GetQuota(ctx context.Context, in *gateway.GetQuotaRequest, opts ...grpc.CallOption) (*provider.GetQuotaResponse, error)
}
// Publisher is the interface for events publisher
type Publisher interface {
Publish(string, interface{}, ...mevents.PublishOption) 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() (GatewayClient, error)
// Graph defines implements the business logic for Service.
type Graph struct {
config *config.Config
mux *chi.Mux
logger *log.Logger
identityBackend identity.Backend
gatewayClient GatewayClient
httpClient HTTPClient
roleService settingssvc.RoleService
spacePropertiesCache *ttlcache.Cache
eventsPublisher events.Publisher
}
// ServeHTTP implements the Service interface.
func (g Graph) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.mux.ServeHTTP(w, r)
}
// GetClient returns a gateway client to talk to reva
func (g Graph) GetGatewayClient() GatewayClient {
return g.gatewayClient
}
// GetClient returns a gateway client to talk to reva
func (g Graph) GetHTTPClient() HTTPClient {
return g.httpClient
}
func (g Graph) publishEvent(ev interface{}) {
if err := events.Publish(g.eventsPublisher, ev); err != nil {
g.logger.Error().
Err(err).
Msg("could not publish user created event")
}
}
type listResponse struct {
Value interface{} `json:"value,omitempty"`
}
const (
NoSpaceFoundMessage = "space with id `%s` not found"
ListStorageSpacesTransportErr = "transport error sending list storage spaces grpc request"
ListStorageSpacesReturnsErr = "list storage spaces grpc request returns an errorcode in the response"
ReadmeSpecialFolderName = "readme"
SpaceImageSpecialFolderName = "image"
)
@@ -0,0 +1,13 @@
package svc_test
import (
"testing"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestGraph(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Graph Suite")
}
@@ -0,0 +1,304 @@
package svc_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/cs3org/reva/v2/pkg/rgrpc/status"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/extensions/graph/mocks"
"github.com/owncloud/ocis/extensions/graph/pkg/config/defaults"
service "github.com/owncloud/ocis/extensions/graph/pkg/service/v0"
"github.com/owncloud/ocis/extensions/graph/pkg/service/v0/errorcode"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Graph", func() {
var (
svc service.Service
gatewayClient *mocks.GatewayClient
httpClient *mocks.HTTPClient
eventsPublisher mocks.Publisher
ctx context.Context
)
JustBeforeEach(func() {
ctx = context.Background()
gatewayClient = &mocks.GatewayClient{}
httpClient = &mocks.HTTPClient{}
eventsPublisher = mocks.Publisher{}
svc = service.NewService(
service.Config(defaults.DefaultConfig()),
service.WithGatewayClient(gatewayClient),
service.WithHTTPClient(httpClient),
service.EventsPublisher(&eventsPublisher),
)
})
Describe("NewService", func() {
It("returns a service", func() {
Expect(svc).ToNot(BeNil())
})
})
Describe("drive", func() {
It("can list an empty list of spaces", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{},
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives", nil)
rr := httptest.NewRecorder()
svc.GetDrives(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
})
It("can list a space without owner", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{
{
Id: &provider.StorageSpaceId{OpaqueId: "sameID"},
SpaceType: "aspacetype",
Root: &provider.ResourceId{
StorageId: "sameID",
OpaqueId: "sameID",
},
Name: "aspacename",
},
},
}, nil)
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Return(&gateway.InitiateFileDownloadResponse{
Status: status.NewNotFound(ctx, "not found"),
}, nil)
gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{
Status: status.NewUnimplemented(ctx, fmt.Errorf("not supported"), "not supported"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives", nil)
rr := httptest.NewRecorder()
svc.GetDrives(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
body, _ := io.ReadAll(rr.Body)
Expect(body).To(MatchJSON(`
{
"value":[
{
"driveType":"aspacetype",
"id":"sameID",
"name":"aspacename",
"root":{
"id":"sameID!sameID",
"webDavUrl":"https://localhost:9200/dav/spaces/sameID"
}
}
]
}
`))
})
It("can list a spaces with sort", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{
{
Id: &provider.StorageSpaceId{OpaqueId: "bsameID"},
SpaceType: "bspacetype",
Root: &provider.ResourceId{
StorageId: "bsameID",
OpaqueId: "bsameID",
},
Name: "bspacename",
Opaque: &typesv1beta1.Opaque{
Map: map[string]*typesv1beta1.OpaqueEntry{
"spaceAlias": {Decoder: "plain", Value: []byte("bspacetype/bspacename")},
"etag": {Decoder: "plain", Value: []byte("123456789")},
},
},
},
{
Id: &provider.StorageSpaceId{OpaqueId: "asameID"},
SpaceType: "aspacetype",
Root: &provider.ResourceId{
StorageId: "asameID",
OpaqueId: "asameID",
},
Name: "aspacename",
Opaque: &typesv1beta1.Opaque{
Map: map[string]*typesv1beta1.OpaqueEntry{
"spaceAlias": {Decoder: "plain", Value: []byte("aspacetype/aspacename")},
"etag": {Decoder: "plain", Value: []byte("101112131415")},
},
},
},
},
}, nil)
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Return(&gateway.InitiateFileDownloadResponse{
Status: status.NewNotFound(ctx, "not found"),
}, nil)
gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{
Status: status.NewUnimplemented(ctx, fmt.Errorf("not supported"), "not supported"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives?$orderby=name%20asc", nil)
rr := httptest.NewRecorder()
svc.GetDrives(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
body, _ := io.ReadAll(rr.Body)
Expect(body).To(MatchJSON(`
{
"value":[
{
"driveAlias":"aspacetype/aspacename",
"driveType":"aspacetype",
"id":"asameID",
"name":"aspacename",
"root":{
"eTag":"101112131415",
"id":"asameID!asameID",
"webDavUrl":"https://localhost:9200/dav/spaces/asameID"
}
},
{
"driveAlias":"bspacetype/bspacename",
"driveType":"bspacetype",
"id":"bsameID",
"name":"bspacename",
"root":{
"eTag":"123456789",
"id":"bsameID!bsameID",
"webDavUrl":"https://localhost:9200/dav/spaces/bsameID"
}
}
]
}
`))
})
It("can list a spaces type mountpoint", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{
{
Id: &provider.StorageSpaceId{OpaqueId: "aID!differentID"},
SpaceType: "mountpoint",
Root: &provider.ResourceId{
StorageId: "aID",
OpaqueId: "differentID",
},
Name: "New Folder",
Opaque: &typesv1beta1.Opaque{
Map: map[string]*typesv1beta1.OpaqueEntry{
"spaceAlias": {Decoder: "plain", Value: []byte("mountpoint/new-folder")},
"etag": {Decoder: "plain", Value: []byte("101112131415")},
"grantStorageID": {Decoder: "plain", Value: []byte("ownerStorageID")},
"grantOpaqueID": {Decoder: "plain", Value: []byte("opaqueID")},
},
},
},
},
}, nil)
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{
Status: status.NewOK(ctx),
Info: &provider.ResourceInfo{
Etag: "123456789",
Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER,
Id: &provider.ResourceId{StorageId: "ownerStorageID", OpaqueId: "opaqueID"},
Path: "New Folder",
Mtime: &typesv1beta1.Timestamp{Seconds: 1648327606, Nanos: 0},
Size: uint64(1234),
},
}, nil)
gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{
Status: status.NewUnimplemented(ctx, fmt.Errorf("not supported"), "not supported"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives", nil)
rr := httptest.NewRecorder()
svc.GetDrives(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
body, _ := io.ReadAll(rr.Body)
var response map[string][]libregraph.Drive
err := json.Unmarshal(body, &response)
Expect(err).ToNot(HaveOccurred())
Expect(len(response["value"])).To(Equal(1))
value := response["value"][0]
Expect(*value.DriveAlias).To(Equal("mountpoint/new-folder"))
Expect(*value.DriveType).To(Equal("mountpoint"))
Expect(*value.Id).To(Equal("aID!differentID"))
Expect(*value.Name).To(Equal("New Folder"))
Expect(*value.Root.WebDavUrl).To(Equal("https://localhost:9200/dav/spaces/aID!differentID"))
Expect(*value.Root.ETag).To(Equal("101112131415"))
Expect(*value.Root.Id).To(Equal("aID!differentID"))
Expect(*value.Root.RemoteItem.ETag).To(Equal("123456789"))
Expect(*value.Root.RemoteItem.Id).To(Equal("ownerStorageID!opaqueID"))
Expect(value.Root.RemoteItem.LastModifiedDateTime.UTC()).To(Equal(time.Unix(1648327606, 0).UTC()))
Expect(*value.Root.RemoteItem.Folder).To(Equal(libregraph.Folder{}))
Expect(*value.Root.RemoteItem.Name).To(Equal("New Folder"))
Expect(*value.Root.RemoteItem.Size).To(Equal(int64(1234)))
Expect(*value.Root.RemoteItem.WebDavUrl).To(Equal("https://localhost:9200/dav/spaces/ownerStorageID!opaqueID"))
})
It("can not list spaces with wrong sort parameter", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{}}, nil)
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Return(&gateway.InitiateFileDownloadResponse{
Status: status.NewNotFound(ctx, "not found"),
}, nil)
gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{
Status: status.NewUnimplemented(ctx, fmt.Errorf("not supported"), "not supported"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives?$orderby=owner%20asc", nil)
rr := httptest.NewRecorder()
svc.GetDrives(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
body, _ := io.ReadAll(rr.Body)
var libreError libregraph.OdataError
err := json.Unmarshal(body, &libreError)
Expect(err).To(Not(HaveOccurred()))
Expect(libreError.Error.Message).To(Equal("we do not support <owner> as a order parameter"))
Expect(libreError.Error.Code).To(Equal(errorcode.InvalidRequest.String()))
})
It("can list a spaces with invalid query parameter", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{}}, nil)
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Return(&gateway.InitiateFileDownloadResponse{
Status: status.NewNotFound(ctx, "not found"),
}, nil)
gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{
Status: status.NewUnimplemented(ctx, fmt.Errorf("not supported"), "not supported"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives?§orderby=owner%20asc", nil)
rr := httptest.NewRecorder()
svc.GetDrives(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
body, _ := io.ReadAll(rr.Body)
var libreError libregraph.OdataError
err := json.Unmarshal(body, &libreError)
Expect(err).To(Not(HaveOccurred()))
Expect(libreError.Error.Message).To(Equal("Query parameter '§orderby' is not supported. Cause: Query parameter '§orderby' is not supported"))
Expect(libreError.Error.Code).To(Equal(errorcode.InvalidRequest.String()))
})
})
})
+369
View File
@@ -0,0 +1,369 @@
package svc
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"github.com/CiscoM31/godata"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/extensions/graph/pkg/service/v0/errorcode"
"github.com/cs3org/reva/v2/pkg/events"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
const memberRefsLimit = 20
// GetGroups implements the Service interface.
func (g Graph) GetGroups(w http.ResponseWriter, r *http.Request) {
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
odataReq, 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
}
groups, err := g.identityBackend.GetGroups(r.Context(), r.URL.Query())
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
}
groups, err = sortGroups(odataReq, groups)
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, 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) {
grp := libregraph.NewGroup()
err := json.NewDecoder(r.Body).Decode(grp)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
if _, ok := grp.GetDisplayNameOk(); !ok {
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 {
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 {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
if grp != nil && grp.Id != nil {
g.publishEvent(events.GroupCreated{GroupID: *grp.Id})
}
render.Status(r, http.StatusOK)
render.JSON(w, r, grp)
}
// PatchGroup implements the Service interface.
func (g Graph) PatchGroup(w http.ResponseWriter, r *http.Request) {
g.logger.Debug().Msg("Calling PatchGroup")
groupID := chi.URLParam(r, "groupID")
groupID, err := url.PathUnescape(groupID)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
return
}
if groupID == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
return
}
changes := libregraph.NewGroup()
err = json.NewDecoder(r.Body).Decode(changes)
if err != nil {
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) > memberRefsLimit {
errorcode.NotAllowed.Render(w, r, http.StatusInternalServerError,
fmt.Sprintf("Request is limited to %d members", memberRefsLimit))
return
}
memberIDs := make([]string, 0, len(memberRefs))
for _, memberRef := range memberRefs {
memberType, id, err := g.parseMemberRef(memberRef)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "Error parsing member@odata.bind values")
return
}
g.logger.Debug().Str("memberType", memberType).Str("memberid", id).Msg("Add 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 != "users" {
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "Only user are allowed as group members")
return
}
memberIDs = append(memberIDs, id)
}
err = g.identityBackend.AddMembersToGroup(r.Context(), groupID, memberIDs)
}
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
return
}
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// GetGroup implements the Service interface.
func (g Graph) GetGroup(w http.ResponseWriter, r *http.Request) {
groupID := chi.URLParam(r, "groupID")
groupID, err := url.PathUnescape(groupID)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
}
if groupID == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
return
}
group, err := g.identityBackend.GetGroup(r.Context(), groupID)
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
}
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) {
groupID := chi.URLParam(r, "groupID")
groupID, err := url.PathUnescape(groupID)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
return
}
if groupID == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
return
}
err = g.identityBackend.DeleteGroup(r.Context(), groupID)
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
return
}
g.publishEvent(events.GroupDeleted{GroupID: groupID})
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
func (g Graph) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
groupID := chi.URLParam(r, "groupID")
groupID, err := url.PathUnescape(groupID)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
return
}
if groupID == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
return
}
members, err := g.identityBackend.GetGroupMembers(r.Context(), groupID)
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
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) {
g.logger.Info().Msg("Calling PostGroupMember")
groupID := chi.URLParam(r, "groupID")
groupID, err := url.PathUnescape(groupID)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
return
}
if groupID == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
return
}
memberRef := libregraph.NewMemberReference()
err = json.NewDecoder(r.Body).Decode(memberRef)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
memberRefURL, ok := memberRef.GetOdataIdOk()
if !ok {
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "@odata.id refernce is missing")
return
}
memberType, id, err := g.parseMemberRef(*memberRefURL)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "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 != "users" {
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "Only user are allowed as group members")
return
}
g.logger.Debug().Str("memberType", memberType).Str("id", id).Msg("Add Member")
err = g.identityBackend.AddMembersToGroup(r.Context(), groupID, []string{id})
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
return
}
g.publishEvent(events.GroupMemberAdded{GroupID: groupID, UserID: id})
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteGroupMember implements the Service interface.
func (g Graph) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
g.logger.Info().Msg("Calling DeleteGroupMember")
groupID := chi.URLParam(r, "groupID")
groupID, err := url.PathUnescape(groupID)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
return
}
if groupID == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
return
}
memberID := chi.URLParam(r, "memberID")
memberID, err = url.PathUnescape(memberID)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
return
}
if memberID == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
return
}
g.logger.Debug().Str("groupID", groupID).Str("memberID", memberID).Msg("DeleteGroupMember")
err = g.identityBackend.RemoveMemberFromGroup(r.Context(), groupID, memberID)
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
return
}
g.publishEvent(events.GroupMemberRemoved{GroupID: groupID, UserID: memberID})
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
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 sortGroups(req *godata.GoDataRequest, groups []*libregraph.Group) ([]*libregraph.Group, error) {
var sorter sort.Interface
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
return groups, nil
}
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
case "displayName":
sorter = groupsByDisplayName{groups}
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 == "desc" {
sorter = sort.Reverse(sorter)
}
sort.Sort(sorter)
return groups, nil
}
@@ -0,0 +1,100 @@
package svc
import (
"net/http"
"github.com/owncloud/ocis/extensions/graph/pkg/metrics"
)
// NewInstrument returns a service that instruments metrics.
func NewInstrument(next Service, metrics *metrics.Metrics) Service {
return instrument{
next: next,
metrics: metrics,
}
}
type instrument struct {
next Service
metrics *metrics.Metrics
}
// ServeHTTP implements the Service interface.
func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) {
i.next.ServeHTTP(w, r)
}
// GetMe implements the Service interface.
func (i instrument) GetMe(w http.ResponseWriter, r *http.Request) {
i.next.GetMe(w, r)
}
// GetUsers implements the Service interface.
func (i instrument) GetUsers(w http.ResponseWriter, r *http.Request) {
i.next.GetUsers(w, r)
}
// GetUser implements the Service interface.
func (i instrument) GetUser(w http.ResponseWriter, r *http.Request) {
i.next.GetUser(w, r)
}
// PostUser implements the Service interface.
func (i instrument) PostUser(w http.ResponseWriter, r *http.Request) {
i.next.PostUser(w, r)
}
// DeleteUser implements the Service interface.
func (i instrument) DeleteUser(w http.ResponseWriter, r *http.Request) {
i.next.DeleteUser(w, r)
}
// PatchUser implements the Service interface.
func (i instrument) PatchUser(w http.ResponseWriter, r *http.Request) {
i.next.PatchUser(w, r)
}
// GetGroups implements the Service interface.
func (i instrument) GetGroups(w http.ResponseWriter, r *http.Request) {
i.next.GetGroups(w, r)
}
// GetGroup implements the Service interface.
func (i instrument) GetGroup(w http.ResponseWriter, r *http.Request) {
i.next.GetGroup(w, r)
}
// PostGroup implements the Service interface.
func (i instrument) PostGroup(w http.ResponseWriter, r *http.Request) {
i.next.PostGroup(w, r)
}
// PatchGroup implements the Service interface.
func (i instrument) PatchGroup(w http.ResponseWriter, r *http.Request) {
i.next.PatchGroup(w, r)
}
// DeleteGroup implements the Service interface.
func (i instrument) DeleteGroup(w http.ResponseWriter, r *http.Request) {
i.next.DeleteGroup(w, r)
}
// GetGroupMembers implements the Service interface.
func (i instrument) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
i.next.GetGroupMembers(w, r)
}
// PostGroupMember implements the Service interface.
func (i instrument) PostGroupMember(w http.ResponseWriter, r *http.Request) {
i.next.PostGroupMember(w, r)
}
// DeleteGroupMember implements the Service interface.
func (i instrument) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
i.next.DeleteGroupMember(w, r)
}
// GetDrives implements the Service interface.
func (i instrument) GetDrives(w http.ResponseWriter, r *http.Request) {
i.next.GetDrives(w, r)
}
+100
View File
@@ -0,0 +1,100 @@
package svc
import (
"net/http"
"github.com/owncloud/ocis/ocis-pkg/log"
)
// NewLogging returns a service that logs messages.
func NewLogging(next Service, logger log.Logger) Service {
return logging{
next: next,
logger: logger,
}
}
type logging struct {
next Service
logger log.Logger
}
// ServeHTTP implements the Service interface.
func (l logging) ServeHTTP(w http.ResponseWriter, r *http.Request) {
l.next.ServeHTTP(w, r)
}
// GetMe implements the Service interface.
func (l logging) GetMe(w http.ResponseWriter, r *http.Request) {
l.next.GetMe(w, r)
}
// GetUsers implements the Service interface.
func (l logging) GetUsers(w http.ResponseWriter, r *http.Request) {
l.next.GetUsers(w, r)
}
// GetUser implements the Service interface.
func (l logging) GetUser(w http.ResponseWriter, r *http.Request) {
l.next.GetUser(w, r)
}
// PostUser implements the Service interface.
func (l logging) PostUser(w http.ResponseWriter, r *http.Request) {
l.next.PostUser(w, r)
}
// DeleteUser implements the Service interface.
func (l logging) DeleteUser(w http.ResponseWriter, r *http.Request) {
l.next.DeleteUser(w, r)
}
// PatchUser implements the Service interface.
func (l logging) PatchUser(w http.ResponseWriter, r *http.Request) {
l.next.PatchUser(w, r)
}
// GetGroups implements the Service interface.
func (l logging) GetGroups(w http.ResponseWriter, r *http.Request) {
l.next.GetGroups(w, r)
}
// GetGroup implements the Service interface.
func (l logging) GetGroup(w http.ResponseWriter, r *http.Request) {
l.next.GetGroup(w, r)
}
// PostGroup implements the Service interface.
func (l logging) PostGroup(w http.ResponseWriter, r *http.Request) {
l.next.PostGroup(w, r)
}
// PatchGroup implements the Service interface.
func (l logging) PatchGroup(w http.ResponseWriter, r *http.Request) {
l.next.PatchGroup(w, r)
}
// DeleteGroup implements the Service interface.
func (l logging) DeleteGroup(w http.ResponseWriter, r *http.Request) {
l.next.DeleteGroup(w, r)
}
// GetGroupMembers implements the Service interface.
func (l logging) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
l.next.GetGroupMembers(w, r)
}
// PostGroupMember implements the Service interface.
func (l logging) PostGroupMember(w http.ResponseWriter, r *http.Request) {
l.next.PostGroupMember(w, r)
}
// DeleteGroupMember implements the Service interface.
func (l logging) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
l.next.DeleteGroupMember(w, r)
}
// GetDrives implements the Service interface.
func (l logging) GetDrives(w http.ResponseWriter, r *http.Request) {
l.next.GetDrives(w, r)
}
@@ -0,0 +1,9 @@
package net
const (
// "github.com/cs3org/reva/v2/internal/http/services/datagateway" is internal so we redeclare it here
// HeaderTokenTransport holds the header key for the reva transfer token
HeaderTokenTransport = "X-Reva-Transfer"
// HeaderIfModifiedSince is used to mimic/pass on caching headers when using grpc
HeaderIfModifiedSince = "If-Modified-Since"
)
+93
View File
@@ -0,0 +1,93 @@
package svc
import (
"net/http"
"github.com/cs3org/reva/v2/pkg/events"
"github.com/owncloud/ocis/extensions/graph/pkg/config"
"github.com/owncloud/ocis/ocis-pkg/log"
"github.com/owncloud/ocis/ocis-pkg/roles"
settingssvc "github.com/owncloud/ocis/protogen/gen/ocis/services/settings/v0"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Config *config.Config
Middleware []func(http.Handler) http.Handler
GatewayClient GatewayClient
HTTPClient HTTPClient
RoleService settingssvc.RoleService
RoleManager *roles.Manager
EventsPublisher events.Publisher
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// 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
}
}
// WithGatewayClient provides a function to set the gateway client option.
func WithGatewayClient(val GatewayClient) Option {
return func(o *Options) {
o.GatewayClient = val
}
}
// WithHTTPClient provides a function to set the http client option.
func WithHTTPClient(val HTTPClient) Option {
return func(o *Options) {
o.HTTPClient = val
}
}
// RoleService provides a function to set the RoleService option.
func RoleService(val settingssvc.RoleService) Option {
return func(o *Options) {
o.RoleService = 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
}
}
+103
View File
@@ -0,0 +1,103 @@
package svc
import (
"strings"
libregraph "github.com/owncloud/libre-graph-api-go"
)
type spacesSlice []*libregraph.Drive
// Len is the number of elements in the collection.
func (d spacesSlice) Len() int { return len(d) }
// Swap swaps the elements with indexes i and j.
func (d spacesSlice) Swap(i, j int) { d[i], d[j] = d[j], d[i] }
type spacesByName struct {
spacesSlice
}
type spacesByLastModifiedDateTime struct {
spacesSlice
}
// Less reports whether the element with index i
// must sort before the element with index j.
func (s spacesByName) Less(i, j int) bool {
return strings.ToLower(*s.spacesSlice[i].Name) < strings.ToLower(*s.spacesSlice[j].Name)
}
// Less reports whether the element with index i
// must sort before the element with index j.
func (s spacesByLastModifiedDateTime) Less(i, j int) bool {
// compare the items when both dates are set
if s.spacesSlice[i].LastModifiedDateTime != nil && s.spacesSlice[j].LastModifiedDateTime != nil {
return s.spacesSlice[i].LastModifiedDateTime.Before(*s.spacesSlice[j].LastModifiedDateTime)
}
// an item without a timestamp is considered "less than" an item with a timestamp
if s.spacesSlice[i].LastModifiedDateTime == nil && s.spacesSlice[j].LastModifiedDateTime != nil {
return true
}
// an item without a timestamp is considered "less than" an item with a timestamp
if s.spacesSlice[i].LastModifiedDateTime != nil && s.spacesSlice[j].LastModifiedDateTime == nil {
return false
}
// fallback to name if no dateTime is set on both items
return strings.ToLower(*s.spacesSlice[i].Name) < strings.ToLower(*s.spacesSlice[j].Name)
}
type userSlice []*libregraph.User
// Len is the number of elements in the collection.
func (d userSlice) Len() int { return len(d) }
// Swap swaps the elements with indexes i and j.
func (d userSlice) Swap(i, j int) { d[i], d[j] = d[j], d[i] }
type usersByDisplayName struct {
userSlice
}
type usersByMail struct {
userSlice
}
type usersByOnPremisesSamAccountName struct {
userSlice
}
// Less reports whether the element with index i
// must sort before the element with index j.
func (u usersByDisplayName) Less(i, j int) bool {
return strings.ToLower(u.userSlice[i].GetDisplayName()) < strings.ToLower(u.userSlice[j].GetDisplayName())
}
// Less reports whether the element with index i
// must sort before the element with index j.
func (u usersByMail) Less(i, j int) bool {
return strings.ToLower(u.userSlice[i].GetMail()) < strings.ToLower(u.userSlice[j].GetMail())
}
// Less reports whether the element with index i
// must sort before the element with index j.
func (u usersByOnPremisesSamAccountName) Less(i, j int) bool {
return strings.ToLower(u.userSlice[i].GetOnPremisesSamAccountName()) < strings.ToLower(u.userSlice[j].GetOnPremisesSamAccountName())
}
type groupSlice []*libregraph.Group
// Len is the number of elements in the collection.
func (d groupSlice) Len() int { return len(d) }
// Swap swaps the elements with indexes i and j.
func (d groupSlice) Swap(i, j int) { d[i], d[j] = d[j], d[i] }
type groupsByDisplayName struct {
groupSlice
}
// Less reports whether the element with index i
// must sort before the element with index j.
func (g groupsByDisplayName) Less(i, j int) bool {
return strings.ToLower(g.groupSlice[i].GetDisplayName()) < strings.ToLower(g.groupSlice[j].GetDisplayName())
}
+201
View File
@@ -0,0 +1,201 @@
package svc
import (
"crypto/tls"
"net/http"
"strconv"
"time"
"github.com/ReneKroon/ttlcache/v2"
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/owncloud/ocis/extensions/graph/pkg/identity"
"github.com/owncloud/ocis/extensions/graph/pkg/identity/ldap"
graphm "github.com/owncloud/ocis/extensions/graph/pkg/middleware"
"github.com/owncloud/ocis/ocis-pkg/account"
opkgm "github.com/owncloud/ocis/ocis-pkg/middleware"
"github.com/owncloud/ocis/ocis-pkg/roles"
"github.com/owncloud/ocis/ocis-pkg/service/grpc"
settingssvc "github.com/owncloud/ocis/protogen/gen/ocis/services/settings/v0"
)
const (
// HeaderPurge defines the header name for the purge header.
HeaderPurge = "Purge"
)
// Service defines the extension handlers.
type Service interface {
ServeHTTP(http.ResponseWriter, *http.Request)
GetMe(http.ResponseWriter, *http.Request)
GetUsers(http.ResponseWriter, *http.Request)
GetUser(http.ResponseWriter, *http.Request)
PostUser(http.ResponseWriter, *http.Request)
DeleteUser(http.ResponseWriter, *http.Request)
PatchUser(http.ResponseWriter, *http.Request)
GetGroups(http.ResponseWriter, *http.Request)
GetGroup(http.ResponseWriter, *http.Request)
PostGroup(http.ResponseWriter, *http.Request)
PatchGroup(http.ResponseWriter, *http.Request)
DeleteGroup(http.ResponseWriter, *http.Request)
GetGroupMembers(http.ResponseWriter, *http.Request)
PostGroupMember(http.ResponseWriter, *http.Request)
DeleteGroupMember(http.ResponseWriter, *http.Request)
GetDrives(w http.ResponseWriter, r *http.Request)
}
// NewService returns a service implementation for Service.
func NewService(opts ...Option) Service {
options := newOptions(opts...)
m := chi.NewMux()
m.Use(options.Middleware...)
var backend identity.Backend
switch options.Config.Identity.Backend {
case "cs3":
backend = &identity.CS3{
Config: &options.Config.Reva,
Logger: &options.Logger,
}
case "ldap":
var err error
var tlsConf *tls.Config
if options.Config.Identity.LDAP.Insecure {
tlsConf = &tls.Config{
//nolint:gosec // We need the ability to run with "insecure" (dev/testing)
InsecureSkipVerify: options.Config.Identity.LDAP.Insecure,
}
}
conn := ldap.NewLDAPWithReconnect(&options.Logger,
ldap.Config{
URI: options.Config.Identity.LDAP.URI,
BindDN: options.Config.Identity.LDAP.BindDN,
BindPassword: options.Config.Identity.LDAP.BindPassword,
TLSConfig: tlsConf,
},
)
if backend, err = identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger); err != nil {
options.Logger.Error().Msgf("Error initializing LDAP Backend: '%s'", err)
return nil
}
default:
options.Logger.Error().Msgf("Unknown Identity Backend: '%s'", options.Config.Identity.Backend)
return nil
}
svc := Graph{
config: options.Config,
mux: m,
logger: &options.Logger,
identityBackend: backend,
spacePropertiesCache: ttlcache.NewCache(),
eventsPublisher: options.EventsPublisher,
}
if options.GatewayClient == nil {
var err error
svc.gatewayClient, err = pool.GetGatewayServiceClient(options.Config.Reva.Address)
if err != nil {
options.Logger.Error().Err(err).Msg("Could not get gateway client")
return nil
}
} else {
svc.gatewayClient = options.GatewayClient
}
if options.HTTPClient == nil {
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{
InsecureSkipVerify: options.Config.Spaces.Insecure, //nolint:gosec
}
svc.httpClient = &http.Client{}
} else {
svc.httpClient = options.HTTPClient
}
if options.RoleService == nil {
svc.roleService = settingssvc.NewRoleService("com.owncloud.api.settings", grpc.DefaultClient)
} else {
svc.roleService = options.RoleService
}
roleManager := options.RoleManager
if roleManager == nil {
m := roles.NewManager(
roles.CacheSize(1024),
roles.CacheTTL(time.Hour),
roles.Logger(options.Logger),
roles.RoleService(svc.roleService),
)
roleManager = &m
}
requireAdmin := graphm.RequireAdmin(roleManager, options.Logger)
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
r.Use(middleware.StripSlashes)
r.Route("/v1.0", func(r chi.Router) {
r.Route("/me", func(r chi.Router) {
r.Get("/", svc.GetMe)
r.Get("/drives", svc.GetDrives)
r.Get("/drive/root/children", svc.GetRootDriveChildren)
})
r.Route("/users", func(r chi.Router) {
r.With(requireAdmin).Get("/", svc.GetUsers)
r.With(requireAdmin).Post("/", svc.PostUser)
r.Route("/{userID}", func(r chi.Router) {
r.Get("/", svc.GetUser)
r.With(requireAdmin).Delete("/", svc.DeleteUser)
r.With(requireAdmin).Patch("/", svc.PatchUser)
})
})
r.Route("/groups", func(r chi.Router) {
r.With(requireAdmin).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.Group(func(r chi.Router) {
r.Use(opkgm.ExtractAccountUUID(
account.Logger(options.Logger),
account.JWTSecret(options.Config.TokenManager.JWTSecret)),
)
r.Route("/drives", func(r chi.Router) {
r.Get("/", svc.GetDrives)
r.Post("/", svc.CreateDrive)
r.Route("/{driveID}", func(r chi.Router) {
r.Patch("/", svc.UpdateDrive)
r.Get("/", svc.GetSingleDrive)
r.Delete("/", svc.DeleteDrive)
})
})
})
})
})
return svc
}
// 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,96 @@
package svc
import (
"net/http"
)
// NewTracing returns a service that instruments traces.
func NewTracing(next Service) Service {
return tracing{
next: next,
}
}
type tracing struct {
next Service
}
// ServeHTTP implements the Service interface.
func (t tracing) ServeHTTP(w http.ResponseWriter, r *http.Request) {
t.next.ServeHTTP(w, r)
}
// GetMe implements the Service interface.
func (t tracing) GetMe(w http.ResponseWriter, r *http.Request) {
t.next.GetMe(w, r)
}
// GetUsers implements the Service interface.
func (t tracing) GetUsers(w http.ResponseWriter, r *http.Request) {
t.next.GetUsers(w, r)
}
// GetUser implements the Service interface.
func (t tracing) GetUser(w http.ResponseWriter, r *http.Request) {
t.next.GetUser(w, r)
}
// PostUser implements the Service interface.
func (t tracing) PostUser(w http.ResponseWriter, r *http.Request) {
t.next.PostUser(w, r)
}
// DeleteUser implements the Service interface.
func (t tracing) DeleteUser(w http.ResponseWriter, r *http.Request) {
t.next.DeleteUser(w, r)
}
// PatchUser implements the Service interface.
func (t tracing) PatchUser(w http.ResponseWriter, r *http.Request) {
t.next.PatchUser(w, r)
}
// GetGroups implements the Service interface.
func (t tracing) GetGroups(w http.ResponseWriter, r *http.Request) {
t.next.GetGroups(w, r)
}
// GetGroup implements the Service interface.
func (t tracing) GetGroup(w http.ResponseWriter, r *http.Request) {
t.next.GetGroup(w, r)
}
// PostGroup implements the Service interface.
func (t tracing) PostGroup(w http.ResponseWriter, r *http.Request) {
t.next.PostGroup(w, r)
}
// PatchGroup implements the Service interface.
func (t tracing) PatchGroup(w http.ResponseWriter, r *http.Request) {
t.next.PatchGroup(w, r)
}
// DeleteGroup implements the Service interface.
func (t tracing) DeleteGroup(w http.ResponseWriter, r *http.Request) {
t.next.DeleteGroup(w, r)
}
// GetGroupMembers implements the Service interface.
func (t tracing) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
t.next.GetGroupMembers(w, r)
}
// PostGroupMember implements the Service interface.
func (t tracing) PostGroupMember(w http.ResponseWriter, r *http.Request) {
t.next.PostGroupMember(w, r)
}
// DeleteGroupMember implements the Service interface.
func (t tracing) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
t.next.DeleteGroupMember(w, r)
}
// GetDrives implements the Service interface.
func (t tracing) GetDrives(w http.ResponseWriter, r *http.Request) {
t.next.GetDrives(w, r)
}
+299
View File
@@ -0,0 +1,299 @@
package svc
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"github.com/CiscoM31/godata"
revactx "github.com/cs3org/reva/v2/pkg/ctx"
"github.com/cs3org/reva/v2/pkg/events"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/extensions/graph/pkg/identity"
"github.com/owncloud/ocis/extensions/graph/pkg/service/v0/errorcode"
settings "github.com/owncloud/ocis/protogen/gen/ocis/services/settings/v0"
settingssvc "github.com/owncloud/ocis/settings/pkg/service/v0"
)
// GetMe implements the Service interface.
func (g Graph) GetMe(w http.ResponseWriter, r *http.Request) {
u, ok := revactx.ContextGetUser(r.Context())
if !ok {
g.logger.Error().Msg("user not in context")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "user not in context")
return
}
g.logger.Info().Interface("user", u).Msg("User in /me")
me := identity.CreateUserModelFromCS3(u)
render.Status(r, http.StatusOK)
render.JSON(w, r, me)
}
// GetUsers implements the Service interface.
func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) {
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
odataReq, 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
}
users, err := g.identityBackend.GetUsers(r.Context(), r.URL.Query())
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
return
}
users, err = sortUsers(odataReq, users)
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &listResponse{Value: users})
}
func (g Graph) PostUser(w http.ResponseWriter, r *http.Request) {
u := libregraph.NewUser()
err := json.NewDecoder(r.Body).Decode(u)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
if _, ok := u.GetDisplayNameOk(); !ok {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'displayName'")
return
}
if accountName, ok := u.GetOnPremisesSamAccountNameOk(); ok {
if !isValidUsername(*accountName) {
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 {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'onPremisesSamAccountName'")
return
}
if mail, ok := u.GetMailOk(); ok {
if !isValidEmail(*mail) {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
fmt.Sprintf("'%s' is not a valid email address", *u.Mail))
return
}
} else {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'mail'")
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 {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "user id is a read-only attribute")
return
}
if u, err = g.identityBackend.CreateUser(r.Context(), *u); err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
// All users get the user role by default currently.
// to all new users for now, as create Account request does not have any role field
if g.roleService == nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not assign role to account: roleService not configured")
return
}
if _, err = g.roleService.AssignRoleToUser(r.Context(), &settings.AssignRoleToUserRequest{
AccountUuid: *u.Id,
RoleId: settingssvc.BundleUUIDRoleUser,
}); err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, fmt.Sprintf("could not assign role to account %s", err.Error()))
return
}
g.publishEvent(events.UserCreated{UserID: *u.Id})
render.Status(r, http.StatusOK)
render.JSON(w, r, u)
}
// GetUser implements the Service interface.
func (g Graph) GetUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
userID, err := url.PathUnescape(userID)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
}
if userID == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
return
}
user, err := g.identityBackend.GetUser(r.Context(), userID)
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, user)
}
func (g Graph) DeleteUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
userID, err := url.PathUnescape(userID)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
}
if userID == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
return
}
err = g.identityBackend.DeleteUser(r.Context(), userID)
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
}
g.publishEvent(events.UserDeleted{UserID: userID})
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// PatchUser implements the Service Interface. Updates the specified attributes of an
// ExistingUser
func (g Graph) PatchUser(w http.ResponseWriter, r *http.Request) {
nameOrID := chi.URLParam(r, "userID")
nameOrID, err := url.PathUnescape(nameOrID)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
}
if nameOrID == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
return
}
changes := libregraph.NewUser()
err = json.NewDecoder(r.Body).Decode(changes)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
var features []events.UserFeature
if mail, ok := changes.GetMailOk(); ok {
if !isValidEmail(*mail) {
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})
}
u, err := g.identityBackend.UpdateUser(r.Context(), nameOrID, *changes)
if err != nil {
var errcode errorcode.Error
if errors.As(err, &errcode) {
errcode.Render(w, r)
} else {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
}
g.publishEvent(
events.UserFeatureChanged{
UserID: nameOrID,
Features: features,
},
)
render.Status(r, http.StatusOK)
render.JSON(w, r, u)
}
// We want to allow email addresses as usernames so they show up when using them in ACLs on storages that allow integration with our glauth LDAP service
// so we are adding a few restrictions from https://stackoverflow.com/questions/6949667/what-are-the-real-rules-for-linux-usernames-on-centos-6-and-rhel-6
// names should not start with numbers
var usernameRegex = regexp.MustCompile("^[a-zA-Z_][a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]*(@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)*$")
func isValidUsername(e string) bool {
if len(e) < 1 && len(e) > 254 {
return false
}
return usernameRegex.MatchString(e)
}
// regex from https://www.w3.org/TR/2016/REC-html51-20161101/sec-forms.html#valid-e-mail-address
var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
func isValidEmail(e string) bool {
if len(e) < 3 && len(e) > 254 {
return false
}
return emailRegex.MatchString(e)
}
func sortUsers(req *godata.GoDataRequest, users []*libregraph.User) ([]*libregraph.User, error) {
var sorter sort.Interface
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
return users, nil
}
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
case "displayName":
sorter = usersByDisplayName{users}
case "mail":
sorter = usersByMail{users}
case "onPremisesSamAccountName":
sorter = usersByOnPremisesSamAccountName{users}
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 == "desc" {
sorter = sort.Reverse(sorter)
}
sort.Sort(sorter)
return users, nil
}