rename folder extensions -> services

Signed-off-by: Christian Richter <crichter@owncloud.com>
This commit is contained in:
Christian Richter
2022-06-27 14:05:36 +02:00
parent 1aea93d8cd
commit 78064e6bab
926 changed files with 0 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
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/storagespace"
"github.com/go-chi/render"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/v2/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() + storagespace.FormatResourceID(*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(storagespace.FormatResourceID(*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(storagespace.FormatResourceID(*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()
res, err := client.GetPath(ctx, &storageprovider.GetPathRequest{ResourceId: &id})
if err != nil {
return "", err
}
if res.Status.Code != cs3rpc.Code_CODE_OK {
return "", fmt.Errorf("could not stat %v: %s", id, res.Status.Message)
}
return res.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 {
rid, _ := storagespace.ParseID(string(itemID.Value))
spaceItem := g.getSpecialDriveItem(ctx, rid, 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.StorageId == "" && id.OpaqueId == "" {
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)}
webdavURL := *baseURL
webdavURL.Path = path.Join(webdavURL.Path, space.Id.OpaqueId, itemPath)
spaceItem.WebDavUrl = libregraph.PtrString(webdavURL.String())
return spaceItem
}
+851
View File
@@ -0,0 +1,851 @@
package svc
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"net/url"
"path"
"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/storagespace"
"github.com/cs3org/reva/v2/pkg/utils"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/service/v0/errorcode"
settingsServiceExt "github.com/owncloud/ocis/v2/extensions/settings/pkg/service/v0"
"github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
v0 "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
merrors "go-micro.dev/v4/errors"
)
// GetDrives lists all drives the current user has access to
func (g Graph) GetDrives(w http.ResponseWriter, r *http.Request) {
g.getDrives(w, r, false)
}
// GetAllDrives lists all drives, including other user's drives, if the current
// user has the permission.
func (g Graph) GetAllDrives(w http.ResponseWriter, r *http.Request) {
g.getDrives(w, r, true)
}
// getDrives implements the Service interface.
func (g Graph) getDrives(w http.ResponseWriter, r *http.Request, unrestricted bool) {
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.Debug().
Interface("query", r.URL.Query()).
Bool("unrestricted", unrestricted).
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, unrestricted)
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, _ := url.PathUnescape(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, true)
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:
_, sID := storagespace.SplitStorageID(identifierParts[0])
root.StorageId, root.OpaqueId = identifierParts[0], sID
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
}
// can't access disabled space
if utils.ReadPlainFromOpaque(storageSpace.Opaque, "trashed") != "trashed" {
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, unrestricted bool) (*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,
},
"unrestricted": {
Decoder: "plain",
Value: []byte(strconv.FormatBool(unrestricted)),
},
}},
Filters: filters,
})
return res, err
}
func generateSpaceId(id *storageprovider.ResourceId) (spaceID string) {
spaceID = id.GetStorageId()
// 2nd ID to compare is the opaque ID of the Space Root
spaceID2 := id.GetOpaqueId()
if strings.Contains(spaceID, "$") {
_, spaceID2 = storagespace.SplitStorageID(spaceID)
}
// Append opaqueID only if it is different from the spaceID2
if id.OpaqueId != spaceID2 {
spaceID += "!" + id.OpaqueId
}
return spaceID
}
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")
}
spaceID := generateSpaceId(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}}
// we need to map the permissions to the roles
switch {
// having RemoveGrant qualifies you as a manager
case perm.RemoveGrant:
managerIdentities = append(managerIdentities, identity)
// InitiateFileUpload means you are an editor
case perm.InitiateFileUpload:
editorIdentities = append(editorIdentities, identity)
// Stat permission at least makes you a viewer
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"},
})
}
}
}
drive := &libregraph.Drive{
Id: libregraph.PtrString(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: libregraph.PtrString(storagespace.FormatResourceID(*space.Root)),
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
webDavURL.Path = path.Join(webDavURL.Path, spaceID)
drive.Root.WebDavUrl = libregraph.PtrString(webDavURL.String())
}
// 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, "!")
_, sID := storagespace.SplitStorageID(identifierParts[0])
switch len(identifierParts) {
case 1:
root.StorageId, root.OpaqueId = identifierParts[0], sID
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,
},
})
if err != nil {
g.logger.Error().Err(err).Msg("error deleting storage space")
w.WriteHeader(http.StatusInternalServerError)
return
}
switch dRes.GetStatus().GetCode() {
case cs3rpc.Code_CODE_OK:
w.WriteHeader(http.StatusNoContent)
return
case cs3rpc.Code_CODE_INVALID_ARGUMENT:
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, dRes.Status.Message)
w.WriteHeader(http.StatusBadRequest)
return
case cs3rpc.Code_CODE_PERMISSION_DENIED:
w.WriteHeader(http.StatusForbidden)
return
// don't expose internal error codes to the outside world
default:
g.logger.Error().Err(err).Msg("error deleting storage space")
w.WriteHeader(http.StatusInternalServerError)
return
}
}
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]
}
+117
View File
@@ -0,0 +1,117 @@
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/v2/extensions/graph/pkg/config"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/identity"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
mevents "go-micro.dev/v4/events"
"google.golang.org/grpc"
)
//go:generate make -C ../../.. generate
// GatewayClient is the subset of the gateway.GatewayAPIClient that is being used to interact with the gateway
type GatewayClient interface {
//gateway.GatewayAPIClient
// Authenticates a user.
Authenticate(ctx context.Context, in *gateway.AuthenticateRequest, opts ...grpc.CallOption) (*gateway.AuthenticateResponse, error)
// 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/v2"
. "github.com/onsi/gomega"
)
func TestGraph(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Graph Suite")
}
+312
View File
@@ -0,0 +1,312 @@
package svc_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"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/v2"
. "github.com/onsi/gomega"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/v2/extensions/graph/mocks"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/config"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/config/defaults"
service "github.com/owncloud/ocis/v2/extensions/graph/pkg/service/v0"
"github.com/owncloud/ocis/v2/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
cfg *config.Config
)
JustBeforeEach(func() {
ctx = context.Background()
cfg = defaults.FullDefaultConfig()
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
cfg.TokenManager.JWTSecret = "loremipsum"
gatewayClient = &mocks.GatewayClient{}
httpClient = &mocks.HTTPClient{}
eventsPublisher = mocks.Publisher{}
svc = service.NewService(
service.Config(cfg),
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: "prID$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]
webdavURL, _ := url.PathUnescape(*value.Root.WebDavUrl)
Expect(*value.DriveAlias).To(Equal("mountpoint/new-folder"))
Expect(*value.DriveType).To(Equal("mountpoint"))
Expect(*value.Id).To(Equal("prID$aID!differentID"))
Expect(*value.Name).To(Equal("New Folder"))
Expect(webdavURL).To(Equal("https://localhost:9200/dav/spaces/prID$aID!differentID"))
Expect(*value.Root.ETag).To(Equal("101112131415"))
Expect(*value.Root.Id).To(Equal("prID$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()))
})
})
})
+374
View File
@@ -0,0 +1,374 @@
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/v2/extensions/graph/pkg/service/v0/errorcode"
ctxpkg "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"
)
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 {
currentUser := ctxpkg.ContextMustGetUser(r.Context())
g.publishEvent(events.GroupCreated{Executant: currentUser.Id, 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, 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())
}
}
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
}
currentUser := ctxpkg.ContextMustGetUser(r.Context())
g.publishEvent(events.GroupDeleted{Executant: currentUser.Id, 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
}
currentUser := ctxpkg.ContextMustGetUser(r.Context())
g.publishEvent(events.GroupMemberAdded{Executant: currentUser.Id, 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
}
currentUser := ctxpkg.ContextMustGetUser(r.Context())
g.publishEvent(events.GroupMemberRemoved{Executant: currentUser.Id, 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
}
+105
View File
@@ -0,0 +1,105 @@
package svc
import (
"net/http"
"github.com/owncloud/ocis/v2/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)
}
// ChangeOwnPassword implements the Service interface.
func (i instrument) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
i.next.ChangeOwnPassword(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)
}
+105
View File
@@ -0,0 +1,105 @@
package svc
import (
"net/http"
"github.com/owncloud/ocis/v2/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)
}
// ChangeOwnPassword implements the Service interface.
func (l logging) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
l.next.ChangeOwnPassword(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"
)
+102
View File
@@ -0,0 +1,102 @@
package svc
import (
"net/http"
"github.com/cs3org/reva/v2/pkg/events"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/config"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/identity"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/ocis-pkg/roles"
settingssvc "github.com/owncloud/ocis/v2/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
IdentityBackend identity.Backend
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
}
}
// WithIdentityBackend provides a function to set the IdentityBackend option.
func WithIdentityBackend(val identity.Backend) Option {
return func(o *Options) {
o.IdentityBackend = 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())
}
+101
View File
@@ -0,0 +1,101 @@
package svc
import (
"encoding/json"
"net/http"
"strings"
"github.com/CiscoM31/godata"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
revactx "github.com/cs3org/reva/v2/pkg/ctx"
"github.com/cs3org/reva/v2/pkg/events"
"github.com/go-chi/render"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/service/v0/errorcode"
)
// ChangeOwnPassword implements the Service interface. It allows the user to change
// its own password
func (g Graph) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u, ok := revactx.ContextGetUser(ctx)
if !ok {
g.logger.Error().Msg("user not in context")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "user not in context")
return
}
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
_, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
if err != nil {
g.logger.Err(err).Interface("query", r.URL.Query()).Msg("query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
cpw := libregraph.NewPasswordChange()
err = json.NewDecoder(r.Body).Decode(cpw)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
currentPw := cpw.GetCurrentPassword()
if currentPw == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "current password cannot be empty")
return
}
newPw := cpw.GetNewPassword()
if newPw == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "new password cannot be empty")
return
}
if newPw == currentPw {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "new password must be differnt from current password")
return
}
authReq := &gateway.AuthenticateRequest{
Type: "basic",
ClientId: u.Username,
ClientSecret: currentPw,
}
authRes, err := g.gatewayClient.Authenticate(r.Context(), authReq)
if err != nil {
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
if authRes.Status.Code != cs3rpc.Code_CODE_OK {
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "password change failed")
return
}
newPwProfile := libregraph.NewPasswordProfile()
newPwProfile.SetPassword(newPw)
changes := libregraph.NewUser()
changes.SetPasswordProfile(*newPwProfile)
_, err = g.identityBackend.UpdateUser(ctx, u.Id.OpaqueId, *changes)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "password change failed")
g.logger.Debug().Err(err).Str("userid", u.Id.OpaqueId).Msg("failed to update user password")
return
}
currentUser := ctxpkg.ContextMustGetUser(r.Context())
g.publishEvent(
events.UserFeatureChanged{
Executant: currentUser.Id,
UserID: u.Id.OpaqueId,
Features: []events.UserFeature{
{Name: "password", Value: "***"},
},
},
)
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
@@ -0,0 +1,160 @@
package svc_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
revactx "github.com/cs3org/reva/v2/pkg/ctx"
"github.com/cs3org/reva/v2/pkg/rgrpc/status"
"github.com/go-ldap/ldap/v3"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/v2/extensions/graph/mocks"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/config"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/config/defaults"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/identity"
service "github.com/owncloud/ocis/v2/extensions/graph/pkg/service/v0"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/stretchr/testify/mock"
)
type changePwTest struct {
desc string
currentpw string
newpw string
expected int
}
var _ = Describe("Users changing their own password", func() {
var (
svc service.Service
gatewayClient *mocks.GatewayClient
httpClient *mocks.HTTPClient
ldapClient *mocks.Client
ldapConfig config.LDAP
identityBackend identity.Backend
eventsPublisher mocks.Publisher
ctx context.Context
cfg *config.Config
user *userv1beta1.User
err error
)
JustBeforeEach(func() {
ctx = context.Background()
cfg = defaults.FullDefaultConfig()
cfg.TokenManager.JWTSecret = "loremipsum"
gatewayClient = &mocks.GatewayClient{}
ldapClient = mockedLDAPClient()
ldapConfig = config.LDAP{
WriteEnabled: true,
UserDisplayNameAttribute: "displayName",
UserNameAttribute: "uid",
UserEmailAttribute: "mail",
UserIDAttribute: "ownclouduuid",
UserSearchScope: "sub",
GroupNameAttribute: "cn",
GroupIDAttribute: "ownclouduui",
GroupSearchScope: "sub",
}
loggger := log.NewLogger()
identityBackend, err = identity.NewLDAPBackend(ldapClient, ldapConfig, &loggger)
Expect(err).To(BeNil())
httpClient = &mocks.HTTPClient{}
eventsPublisher = mocks.Publisher{}
svc = service.NewService(
service.Config(cfg),
service.WithGatewayClient(gatewayClient),
service.WithIdentityBackend(identityBackend),
service.WithHTTPClient(httpClient),
service.EventsPublisher(&eventsPublisher),
)
user = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "user",
},
}
ctx = revactx.ContextSetUser(ctx, user)
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
})
It("fails if no user in context", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/changePassword", nil)
rr := httptest.NewRecorder()
svc.ChangeOwnPassword(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
DescribeTable("changing the password",
func(current string, newpw string, authresult string, expected int) {
switch authresult {
case "error":
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(nil, errors.New("fail"))
case "deny":
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
Status: status.NewPermissionDenied(ctx, errors.New("wrong password"), "wrong password"),
Token: "authtoken",
}, nil)
default:
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
Status: status.NewOK(ctx),
Token: "authtoken",
}, nil)
}
cpw := libregraph.NewPasswordChange()
cpw.SetCurrentPassword(current)
cpw.SetNewPassword(newpw)
body, _ := json.Marshal(cpw)
b := bytes.NewBuffer(body)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/me/changePassword", b).WithContext(ctx)
rr := httptest.NewRecorder()
svc.ChangeOwnPassword(rr, r)
Expect(rr.Code).To(Equal(expected))
},
Entry("fails when current password is empty", "", "newpassword", "", http.StatusBadRequest),
Entry("fails when new password is empty", "currentpassword", "", "", http.StatusBadRequest),
Entry("fails when current and new password are equal", "password", "password", "", http.StatusBadRequest),
Entry("fails authentication with current password errors", "currentpassword", "newpassword", "error", http.StatusInternalServerError),
Entry("fails when current password is wrong", "currentpassword", "newpassword", "deny", http.StatusInternalServerError),
Entry("succeeds when current password is correct", "currentpassword", "newpassword", "", http.StatusNoContent),
)
})
func mockedLDAPClient() *mocks.Client {
lm := &mocks.Client{}
userEntry := ldap.NewEntry("uid=test", map[string][]string{
"uid": {"test"},
"displayName": {"test"},
"mail": {"test@example.org"},
})
lm.On("Search", mock.Anything, mock.Anything, mock.Anything, mock.Anything,
mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(
&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}},
nil)
mr := ldap.NewModifyRequest("uid=test", nil)
mr.Changes = []ldap.Change{
{
Operation: ldap.ReplaceAttribute,
Modification: ldap.PartialAttribute{
Type: "userPassword",
Vals: []string{"newpassword"},
},
},
}
lm.On("Modify", mr).Return(nil)
return lm
}
+224
View File
@@ -0,0 +1,224 @@
package svc
import (
"crypto/tls"
"crypto/x509"
"io/ioutil"
"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/v2/extensions/graph/pkg/identity"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/identity/ldap"
graphm "github.com/owncloud/ocis/v2/extensions/graph/pkg/middleware"
ocisldap "github.com/owncloud/ocis/v2/ocis-pkg/ldap"
"github.com/owncloud/ocis/v2/ocis-pkg/roles"
"github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
settingssvc "github.com/owncloud/ocis/v2/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)
ChangeOwnPassword(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...)
svc := Graph{
config: options.Config,
mux: m,
logger: &options.Logger,
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.IdentityBackend == nil {
switch options.Config.Identity.Backend {
case "cs3":
svc.identityBackend = &identity.CS3{
Config: options.Config.Reva,
Logger: &options.Logger,
}
case "ldap":
var err error
var tlsConf *tls.Config
if options.Config.Identity.LDAP.Insecure {
// When insecure is set to true then we don't need a certificate.
options.Config.Identity.LDAP.CACert = ""
tlsConf = &tls.Config{
//nolint:gosec // We need the ability to run with "insecure" (dev/testing)
InsecureSkipVerify: options.Config.Identity.LDAP.Insecure,
}
}
if options.Config.Identity.LDAP.CACert != "" {
if err := ocisldap.WaitForCA(options.Logger,
options.Config.Identity.LDAP.Insecure,
options.Config.Identity.LDAP.CACert); err != nil {
options.Logger.Fatal().Err(err).Msg("The configured LDAP CA cert does not exist")
}
if tlsConf == nil {
tlsConf = &tls.Config{}
}
certs := x509.NewCertPool()
pemData, err := ioutil.ReadFile(options.Config.Identity.LDAP.CACert)
if err != nil {
options.Logger.Error().Err(err).Msgf("Error initializing LDAP Backend")
return nil
}
if !certs.AppendCertsFromPEM(pemData) {
options.Logger.Error().Msgf("Error initializing LDAP Backend. Adding CA cert failed")
return nil
}
tlsConf.RootCAs = certs
}
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 svc.identityBackend, 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
}
} else {
svc.identityBackend = options.IdentityBackend
}
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.Post("/changePassword", svc.ChangeOwnPassword)
})
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.Route("/drives", func(r chi.Router) {
r.Get("/", svc.GetAllDrives)
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")
}
}
+101
View File
@@ -0,0 +1,101 @@
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)
}
// ChangeOwnPassword implements the Service interface.
func (t tracing) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
t.next.ChangeOwnPassword(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)
}
+309
View File
@@ -0,0 +1,309 @@
package svc
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"github.com/CiscoM31/godata"
ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
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/v2/extensions/graph/pkg/identity"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/service/v0/errorcode"
settingssvc "github.com/owncloud/ocis/v2/extensions/settings/pkg/service/v0"
settings "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/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 {
var ecErr errorcode.Error
if errors.As(err, &ecErr) {
ecErr.Render(w, r)
} else {
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
}
currentUser := ctxpkg.ContextMustGetUser(r.Context())
g.publishEvent(events.UserCreated{Executant: currentUser.Id, 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, 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())
}
}
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())
}
}
currentUser := ctxpkg.ContextMustGetUser(r.Context())
g.publishEvent(events.UserDeleted{Executant: currentUser.Id, 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())
}
}
currentUser := ctxpkg.ContextMustGetUser(r.Context())
g.publishEvent(
events.UserFeatureChanged{
Executant: currentUser.Id,
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
}