From 472978d4d56c426bf8e1ae1061298a6c508c2b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Mon, 11 May 2026 15:09:00 +0200 Subject: [PATCH] feat(storage): add Kiteworks REST API storage driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the storage.FS interface backed by the Kiteworks REST API. Each top-level Kiteworks folder is exposed as a CS3 StorageSpace. Authentication is OIDC token passthrough — no separate credentials needed. - types.go: Kiteworks data model (FileInfo, DirectoryInfo, User, QuotaInfo, etc.) - client.go: REST API client (CRUD, chunked upload, download, search, quota) - upload.go: chunked upload helper (default 5 MB chunks) - kiteworks.go: storage.FS implementation — spaces, read/write path, grants - loader.go: register "kiteworks" driver via blank import Space type mapping: owned folders → SpaceType "project" received shares (IsSharedWithUser) → SpaceType "mountpoint" Unsupported operations (versioning, recycle bin, locks, explicit deny) return errtypes.NotSupported. Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- pkg/storage/fs/kiteworks/client.go | 277 ++++++++++ pkg/storage/fs/kiteworks/client_test.go | 76 +++ pkg/storage/fs/kiteworks/kiteworks.go | 511 +++++++++++++++++++ pkg/storage/fs/kiteworks/kiteworks_test.go | 14 + pkg/storage/fs/kiteworks/mock_server_test.go | 62 +++ pkg/storage/fs/kiteworks/types.go | 133 +++++ pkg/storage/fs/kiteworks/upload.go | 42 ++ pkg/storage/fs/loader/loader.go | 1 + 8 files changed, 1116 insertions(+) create mode 100644 pkg/storage/fs/kiteworks/client.go create mode 100644 pkg/storage/fs/kiteworks/client_test.go create mode 100644 pkg/storage/fs/kiteworks/kiteworks.go create mode 100644 pkg/storage/fs/kiteworks/kiteworks_test.go create mode 100644 pkg/storage/fs/kiteworks/mock_server_test.go create mode 100644 pkg/storage/fs/kiteworks/types.go create mode 100644 pkg/storage/fs/kiteworks/upload.go diff --git a/pkg/storage/fs/kiteworks/client.go b/pkg/storage/fs/kiteworks/client.go new file mode 100644 index 00000000000..7ecfa126e93 --- /dev/null +++ b/pkg/storage/fs/kiteworks/client.go @@ -0,0 +1,277 @@ +// vendor/github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks/client.go +package kiteworks + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strconv" +) + +const kiteworksAPIVersion = "28" + +// ClientError wraps an unexpected HTTP status +type ClientError struct { + StatusCode int + Body []byte +} + +func (e *ClientError) Error() string { + return fmt.Sprintf("kiteworks: unexpected status %d: %s", e.StatusCode, e.Body) +} + +// Client is a Kiteworks REST API client scoped to a single user token +type Client struct { + endpoint string + token string + httpClient *http.Client +} + +// NewClient creates a Client. Set insecure=true only for development/testing. +func NewClient(endpoint, token string, insecure bool) *Client { + transport := http.DefaultTransport + if insecure { + transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + } + } + return &Client{ + endpoint: endpoint, + token: token, + httpClient: &http.Client{Transport: transport}, + } +} + +func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, c.endpoint+path, body) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("X-Accellion-Version", kiteworksAPIVersion) + req.Header.Set("Content-Type", "application/json") + return req, nil +} + +func (c *Client) do(req *http.Request) (*http.Response, error) { + return c.httpClient.Do(req) +} + +func (c *Client) doJSON(method, path string, body interface{}, out interface{}) error { + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return err + } + bodyReader = bytes.NewReader(data) + } + req, err := c.newRequest(method, path, bodyReader) + if err != nil { + return err + } + resp, err := c.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 300 { + return &ClientError{StatusCode: resp.StatusCode, Body: respBody} + } + if out != nil { + return json.Unmarshal(respBody, out) + } + return nil +} + +// GetTopFolders returns the user's top-level folders +func (c *Client) GetTopFolders() ([]FileInfo, error) { + var result struct { + Data []FileInfo `json:"data"` + } + err := c.doJSON(http.MethodGet, "/rest/folders/top", nil, &result) + return result.Data, err +} + +// GetFolder returns folder metadata by ID +func (c *Client) GetFolder(id string) (*FileInfo, error) { + var fi FileInfo + err := c.doJSON(http.MethodGet, "/rest/folders/"+id, nil, &fi) + return &fi, err +} + +// ListFolder returns the children of a folder +func (c *Client) ListFolder(id string) (*DirectoryInfo, error) { + var result DirectoryInfo + err := c.doJSON(http.MethodGet, "/rest/folders/"+id+"/children", nil, &result) + return &result, err +} + +// CreateFolder creates a subfolder inside parent +func (c *Client) CreateFolder(parentID, name string) (*FileInfo, error) { + var fi FileInfo + err := c.doJSON(http.MethodPost, "/rest/folders/"+parentID+"/folders", + &CreateDir{Name: name, Syncable: true}, &fi) + return &fi, err +} + +// DeleteFolder deletes a folder by ID +func (c *Client) DeleteFolder(id string) error { + return c.doJSON(http.MethodDelete, "/rest/folders/"+id, nil, nil) +} + +// RenameFolder renames a folder +func (c *Client) RenameFolder(id, name string) error { + return c.doJSON(http.MethodPut, "/rest/folders/"+id, + &FolderUpdateRequest{Name: name}, nil) +} + +// GetFile returns file metadata by ID +func (c *Client) GetFile(id string) (*FileInfo, error) { + var fi FileInfo + err := c.doJSON(http.MethodGet, "/rest/files/"+id, nil, &fi) + return &fi, err +} + +// DownloadFile returns a ReadCloser for the file content. The caller must close it. +// rangeHeader is optional (e.g. "bytes=0-1023"). +func (c *Client) DownloadFile(id, rangeHeader string) (*http.Response, error) { + req, err := c.newRequest(http.MethodGet, "/rest/files/"+id+"/content", nil) + if err != nil { + return nil, err + } + if rangeHeader != "" { + req.Header.Set("Range", rangeHeader) + } + return c.do(req) +} + +// DeleteFile deletes a file by ID +func (c *Client) DeleteFile(id string) error { + return c.doJSON(http.MethodDelete, "/rest/files/"+id, nil, nil) +} + +// RenameFile renames a file +func (c *Client) RenameFile(id, name string, replace bool) error { + return c.doJSON(http.MethodPut, "/rest/files/"+id, + &FileUpdateRequest{Name: name, Replace: replace}, nil) +} + +// MoveResource moves a file or folder to a new parent folder +func (c *Client) MoveResource(sourceID, destFolderID string, replace bool) error { + err := c.doJSON(http.MethodPost, "/rest/files/"+sourceID+"/actions/move", + &FileCopyMove{DestFolderID: destFolderID, Replace: replace}, nil) + if err != nil { + if ce, ok := err.(*ClientError); ok && ce.StatusCode == 404 { + return c.doJSON(http.MethodPost, "/rest/folders/"+sourceID+"/actions/move", + &FileCopyMove{DestFolderID: destFolderID, Replace: replace}, nil) + } + return err + } + return nil +} + +// CopyResource copies a file or folder to a destination folder +func (c *Client) CopyResource(sourceID, destFolderID string, replace bool) error { + err := c.doJSON(http.MethodPost, "/rest/files/"+sourceID+"/actions/copy", + &FileCopyMove{DestFolderID: destFolderID, Replace: replace}, nil) + if err != nil { + if ce, ok := err.(*ClientError); ok && ce.StatusCode == 404 { + return c.doJSON(http.MethodPost, "/rest/folders/"+sourceID+"/actions/copy", + &FileCopyMove{DestFolderID: destFolderID, Replace: replace}, nil) + } + return err + } + return nil +} + +// InitiateUpload starts a chunked upload session +func (c *Client) InitiateUpload(parentID, filename string, size int64, numChunks int) (*UploadResult, error) { + var result UploadResult + err := c.doJSON(http.MethodPost, "/rest/folders/"+parentID+"/actions/initiateUpload", + &InitializeUpload{ + Filename: filename, + TotalChunks: numChunks, + TotalFileSize: size, + }, &result) + return &result, err +} + +// UploadChunk uploads a single chunk. Returns FileInfo only on the last chunk. +func (c *Client) UploadChunk(uploadURI, filename string, data []byte, chunkIndex int, isLastChunk bool) (*FileInfo, error) { + var b bytes.Buffer + w := multipart.NewWriter(&b) + + _ = w.WriteField("index", strconv.Itoa(chunkIndex)) + _ = w.WriteField("compressionMode", "NORMAL") + if isLastChunk { + _ = w.WriteField("lastChunk", "1") + } + part, err := w.CreateFormFile("file", filename) + if err != nil { + return nil, err + } + if _, err := part.Write(data); err != nil { + return nil, err + } + w.Close() + + req, err := http.NewRequest(http.MethodPost, c.endpoint+uploadURI, &b) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("X-Accellion-Version", kiteworksAPIVersion) + req.Header.Set("Content-Type", w.FormDataContentType()) + + resp, err := c.do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, &ClientError{StatusCode: resp.StatusCode, Body: respBody} + } + if !isLastChunk { + return nil, nil + } + var fi FileInfo + if err := json.Unmarshal(respBody, &fi); err != nil { + return nil, err + } + return &fi, nil +} + +// GetMe returns the current user's info including quota +func (c *Client) GetMe() (*User, error) { + var u User + err := c.doJSON(http.MethodGet, "/rest/users/me", nil, &u) + return &u, err +} + +// Search searches for a file/folder by path +func (c *Client) Search(path string) ([]FileInfo, error) { + var result struct { + Files []FileInfo `json:"files"` + Folders []FileInfo `json:"folders"` + } + err := c.doJSON(http.MethodGet, "/rest/query?query="+url.QueryEscape(path), nil, &result) + if err != nil { + return nil, err + } + items := append(result.Folders, result.Files...) + return items, nil +} diff --git a/pkg/storage/fs/kiteworks/client_test.go b/pkg/storage/fs/kiteworks/client_test.go new file mode 100644 index 00000000000..cd20b314899 --- /dev/null +++ b/pkg/storage/fs/kiteworks/client_test.go @@ -0,0 +1,76 @@ +// vendor/github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks/client_test.go +package kiteworks_test + +import ( + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks" +) + +var _ = Describe("Client", func() { + var ( + srv *httptest.Server + client *Client + ) + + BeforeEach(func() { + srv = newMockServer() + client = NewClient(srv.URL, "test-token", false) + }) + + AfterEach(func() { + srv.Close() + }) + + Describe("GetTopFolders", func() { + It("returns two top-level folders", func() { + folders, err := client.GetTopFolders() + Expect(err).ToNot(HaveOccurred()) + Expect(folders).To(HaveLen(2)) + Expect(folders[0].ID).To(Equal("f1")) + Expect(folders[1].ID).To(Equal("f2")) + }) + }) + + Describe("GetFolder", func() { + It("returns folder metadata", func() { + fi, err := client.GetFolder("f1") + Expect(err).ToNot(HaveOccurred()) + Expect(fi.ID).To(Equal("f1")) + Expect(fi.Name).To(Equal("MyFiles")) + }) + }) + + Describe("ListFolder", func() { + It("returns folder children", func() { + dir, err := client.ListFolder("f1") + Expect(err).ToNot(HaveOccurred()) + Expect(dir.Files).To(HaveLen(1)) + Expect(dir.Files[0].Name).To(Equal("hello.txt")) + }) + }) + + Describe("GetMe", func() { + It("returns current user with quota", func() { + u, err := client.GetMe() + Expect(err).ToNot(HaveOccurred()) + Expect(u.ID).To(Equal("u1")) + Expect(u.Quota.Allowed).To(Equal(int64(10737418240))) + }) + }) + + Describe("IsSharedWithUser", func() { + It("returns false for owned folder", func() { + folders, err := client.GetTopFolders() + Expect(err).ToNot(HaveOccurred()) + Expect(folders[0].IsSharedWithUser()).To(BeFalse()) + }) + It("returns true for received share", func() { + folders, err := client.GetTopFolders() + Expect(err).ToNot(HaveOccurred()) + Expect(folders[1].IsSharedWithUser()).To(BeTrue()) + }) + }) +}) diff --git a/pkg/storage/fs/kiteworks/kiteworks.go b/pkg/storage/fs/kiteworks/kiteworks.go new file mode 100644 index 00000000000..f5647b2ced3 --- /dev/null +++ b/pkg/storage/fs/kiteworks/kiteworks.go @@ -0,0 +1,511 @@ +// vendor/github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks/kiteworks.go +package kiteworks + +import ( + "context" + "io" + "math" + "net/url" + "strings" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + typespb "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/mitchellh/mapstructure" + "github.com/pkg/errors" + "github.com/rs/zerolog" + + ctxpkg "github.com/owncloud/reva/v2/pkg/ctx" + "github.com/owncloud/reva/v2/pkg/errtypes" + "github.com/owncloud/reva/v2/pkg/events" + "github.com/owncloud/reva/v2/pkg/storage" + "github.com/owncloud/reva/v2/pkg/storage/fs/registry" +) + +func init() { + registry.Register("kiteworks", New) +} + +// Config holds the driver configuration decoded from reva mapstructure +type Config struct { + Endpoint string `mapstructure:"endpoint"` + Insecure bool `mapstructure:"insecure"` + ChunkSize int64 `mapstructure:"chunk_size"` +} + +// Driver implements storage.FS backed by the Kiteworks REST API +type Driver struct { + cfg *Config +} + +// New creates a new kiteworks storage driver +func New(m map[string]interface{}, _ events.Stream, _ *zerolog.Logger) (storage.FS, error) { + cfg := &Config{} + if err := mapstructure.Decode(m, cfg); err != nil { + return nil, errors.Wrap(err, "kiteworks: error decoding config") + } + if cfg.Endpoint == "" { + return nil, errors.New("kiteworks: 'endpoint' must be set") + } + if cfg.ChunkSize <= 0 { + cfg.ChunkSize = defaultChunkSize + } + return &Driver{cfg: cfg}, nil +} + +func (d *Driver) client(ctx context.Context) (*Client, error) { + token, ok := ctxpkg.ContextGetToken(ctx) + if !ok || token == "" { + return nil, errtypes.PermissionDenied("kiteworks: no token in context") + } + return NewClient(d.cfg.Endpoint, token, d.cfg.Insecure), nil +} + +// fileInfoToResourceInfo converts a Kiteworks FileInfo to a CS3 ResourceInfo +func fileInfoToResourceInfo(fi *FileInfo) *provider.ResourceInfo { + ri := &provider.ResourceInfo{ + Id: &provider.ResourceId{ + StorageId: "kiteworks", + OpaqueId: fi.ID, + }, + Path: fi.Name, + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Size: uint64(fi.Size), + } + if fi.IsDir() { + ri.Type = provider.ResourceType_RESOURCE_TYPE_CONTAINER + } + if fi.Modified != nil { + ri.Mtime = &typespb.Timestamp{ + Seconds: uint64(fi.Modified.Unix()), + } + } + if fi.FingerPrints != nil { + for _, fp := range fi.FingerPrints.FingerPrints { + switch fp.Algo { + case "sha1": + ri.Checksum = &provider.ResourceChecksum{ + Type: provider.ResourceChecksumType_RESOURCE_CHECKSUM_TYPE_SHA1, + Sum: fp.Hash, + } + case "md5": + ri.Checksum = &provider.ResourceChecksum{ + Type: provider.ResourceChecksumType_RESOURCE_CHECKSUM_TYPE_MD5, + Sum: fp.Hash, + } + case "adler32": + ri.Checksum = &provider.ResourceChecksum{ + Type: provider.ResourceChecksumType_RESOURCE_CHECKSUM_TYPE_ADLER32, + Sum: fp.Hash, + } + } + } + } + return ri +} + +// spaceFromFileInfo converts a top-level Kiteworks folder to a CS3 StorageSpace +func spaceFromFileInfo(fi *FileInfo) *provider.StorageSpace { + spaceType := "project" + if fi.IsSharedWithUser() { + spaceType = "mountpoint" + } + sp := &provider.StorageSpace{ + Id: &provider.StorageSpaceId{ + OpaqueId: fi.ID, + }, + Root: &provider.ResourceId{ + StorageId: "kiteworks", + OpaqueId: fi.ID, + }, + Name: fi.Name, + SpaceType: spaceType, + } + if fi.Modified != nil { + sp.Mtime = &typespb.Timestamp{ + Seconds: uint64(fi.Modified.Unix()), + } + } + return sp +} + +// Shutdown implements storage.FS +func (d *Driver) Shutdown(_ context.Context) error { return nil } + +// ListStorageSpaces implements storage.FS — returns each top-level Kiteworks folder as a space +func (d *Driver) ListStorageSpaces(ctx context.Context, _ []*provider.ListStorageSpacesRequest_Filter, _ bool) ([]*provider.StorageSpace, error) { + c, err := d.client(ctx) + if err != nil { + return nil, err + } + folders, err := c.GetTopFolders() + if err != nil { + return nil, err + } + spaces := make([]*provider.StorageSpace, 0, len(folders)) + for i := range folders { + spaces = append(spaces, spaceFromFileInfo(&folders[i])) + } + return spaces, nil +} + +// GetQuota implements storage.FS +func (d *Driver) GetQuota(ctx context.Context, _ *provider.Reference) (uint64, uint64, uint64, error) { + c, err := d.client(ctx) + if err != nil { + return 0, 0, 0, err + } + u, err := c.GetMe() + if err != nil { + return 0, 0, 0, err + } + total := uint64(u.Quota.Allowed) + used := uint64(u.Quota.Used) + var remaining uint64 + if total > used { + remaining = total - used + } + return total, used, remaining, nil +} + +// GetMD implements storage.FS +func (d *Driver) GetMD(ctx context.Context, ref *provider.Reference, _, _ []string) (*provider.ResourceInfo, error) { + c, err := d.client(ctx) + if err != nil { + return nil, err + } + id := ref.GetResourceId().GetOpaqueId() + if id == "" && ref.GetPath() == "" { + return nil, errtypes.NotFound("kiteworks: reference has no id or path") + } + if id == "" && ref.GetPath() != "" { + // path-based lookup via search + results, err := c.Search(ref.GetPath()) + if err != nil { + return nil, err + } + if len(results) == 0 { + return nil, errtypes.NotFound(ref.GetPath()) + } + return fileInfoToResourceInfo(&results[0]), nil + } + // Try folder first, fall back to file + fi, err := c.GetFolder(id) + if err != nil { + if ce, ok := err.(*ClientError); ok && ce.StatusCode == 404 { + fi, err = c.GetFile(id) + if err != nil { + return nil, err + } + } else { + return nil, err + } + } + return fileInfoToResourceInfo(fi), nil +} + +// ListFolder implements storage.FS +func (d *Driver) ListFolder(ctx context.Context, ref *provider.Reference, _, _ []string) ([]*provider.ResourceInfo, error) { + c, err := d.client(ctx) + if err != nil { + return nil, err + } + id := ref.GetResourceId().GetOpaqueId() + if id == "" { + return nil, errtypes.NotFound("kiteworks: reference has no id") + } + dir, err := c.ListFolder(id) + if err != nil { + return nil, err + } + var infos []*provider.ResourceInfo + for i := range dir.Folders { + infos = append(infos, fileInfoToResourceInfo(&dir.Folders[i])) + } + for i := range dir.Files { + infos = append(infos, fileInfoToResourceInfo(&dir.Files[i])) + } + return infos, nil +} + +// Download implements storage.FS +func (d *Driver) Download(ctx context.Context, ref *provider.Reference, openReaderFunc func(*provider.ResourceInfo) bool) (*provider.ResourceInfo, io.ReadCloser, error) { + c, err := d.client(ctx) + if err != nil { + return nil, nil, err + } + id := ref.GetResourceId().GetOpaqueId() + if id == "" { + return nil, nil, errtypes.NotFound("kiteworks: reference has no id") + } + fi, err := c.GetFile(id) + if err != nil { + return nil, nil, err + } + ri := fileInfoToResourceInfo(fi) + if openReaderFunc != nil && !openReaderFunc(ri) { + return ri, nil, nil + } + resp, err := c.DownloadFile(id, "") + if err != nil { + return nil, nil, err + } + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, nil, &ClientError{StatusCode: resp.StatusCode, Body: body} + } + return ri, resp.Body, nil +} + +// GetPathByID implements storage.FS +func (d *Driver) GetPathByID(ctx context.Context, id *provider.ResourceId) (string, error) { + if id == nil || id.OpaqueId == "" { + return "", errtypes.NotFound("kiteworks: missing resource id") + } + c, err := d.client(ctx) + if err != nil { + return "", err + } + // Try folder first, fall back to file + fi, err := c.GetFolder(id.OpaqueId) + if err != nil { + if ce, ok := err.(*ClientError); ok && ce.StatusCode == 404 { + fi, err = c.GetFile(id.OpaqueId) + if err != nil { + return "", err + } + } else { + return "", err + } + } + return fi.Path, nil +} + +// --- Stubbed / not-supported operations --- + +func (d *Driver) CreateStorageSpace(_ context.Context, _ *provider.CreateStorageSpaceRequest) (*provider.CreateStorageSpaceResponse, error) { + return nil, errtypes.NotSupported("kiteworks: CreateStorageSpace") +} +func (d *Driver) UpdateStorageSpace(_ context.Context, _ *provider.UpdateStorageSpaceRequest) (*provider.UpdateStorageSpaceResponse, error) { + return nil, errtypes.NotSupported("kiteworks: UpdateStorageSpace") +} +func (d *Driver) DeleteStorageSpace(_ context.Context, _ *provider.DeleteStorageSpaceRequest) error { + return errtypes.NotSupported("kiteworks: DeleteStorageSpace") +} +func (d *Driver) CreateHome(_ context.Context) error { + return errtypes.NotSupported("kiteworks: CreateHome") +} +func (d *Driver) GetHome(_ context.Context) (string, error) { + return "", errtypes.NotSupported("kiteworks: GetHome") +} +func (d *Driver) CreateReference(_ context.Context, _ string, _ *url.URL) error { + return errtypes.NotSupported("kiteworks: CreateReference") +} +func (d *Driver) CreateDir(ctx context.Context, ref *provider.Reference) error { + c, err := d.client(ctx) + if err != nil { + return err + } + parentID := ref.GetResourceId().GetOpaqueId() + name := ref.GetPath() + if parentID == "" || name == "" { + return errtypes.NotFound("kiteworks: CreateDir requires a parent ID and name") + } + _, err = c.CreateFolder(parentID, name) + return err +} +func (d *Driver) TouchFile(ctx context.Context, ref *provider.Reference, _ bool, _ string) error { + c, err := d.client(ctx) + if err != nil { + return err + } + parentID := ref.GetResourceId().GetOpaqueId() + name := ref.GetPath() + if parentID == "" || name == "" { + return errtypes.NotFound("kiteworks: TouchFile requires a parent ID and name") + } + _, err = uploadFile(c, parentID, name, 0, strings.NewReader(""), d.cfg.ChunkSize) + return err +} +func (d *Driver) Delete(ctx context.Context, ref *provider.Reference) error { + c, err := d.client(ctx) + if err != nil { + return err + } + id := ref.GetResourceId().GetOpaqueId() + if id == "" { + return errtypes.NotFound("kiteworks: reference has no id") + } + err = c.DeleteFolder(id) + if err != nil { + if ce, ok := err.(*ClientError); ok && ce.StatusCode == 404 { + return c.DeleteFile(id) + } + return err + } + return nil +} +func (d *Driver) Move(ctx context.Context, oldRef, newRef *provider.Reference) error { + c, err := d.client(ctx) + if err != nil { + return err + } + sourceID := oldRef.GetResourceId().GetOpaqueId() + if sourceID == "" { + return errtypes.NotFound("kiteworks: source reference has no id") + } + destFolderID := newRef.GetResourceId().GetOpaqueId() + if destFolderID == "" { + // rename: try file first, fall back to folder + err = c.RenameFile(sourceID, newRef.GetPath(), false) + if err != nil { + if ce, ok := err.(*ClientError); ok && ce.StatusCode == 404 { + return c.RenameFolder(sourceID, newRef.GetPath()) + } + return err + } + return nil + } + return c.MoveResource(sourceID, destFolderID, false) +} +func (d *Driver) InitiateUpload(ctx context.Context, ref *provider.Reference, uploadLength int64, metadata map[string]string) (map[string]string, error) { + c, err := d.client(ctx) + if err != nil { + return nil, err + } + parentID := ref.GetResourceId().GetOpaqueId() + filename := metadata["filename"] + if filename == "" { + filename = ref.GetPath() + } + numChunks := 1 + if uploadLength > 0 { + numChunks = int(math.Ceil(float64(uploadLength) / float64(d.cfg.ChunkSize))) + } + result, err := c.InitiateUpload(parentID, filename, uploadLength, numChunks) + if err != nil { + return nil, err + } + return map[string]string{ + "uploadID": result.ID, + "uploadURI": result.URI, + "filename": filename, + "parentID": parentID, + }, nil +} +func (d *Driver) Upload(ctx context.Context, req storage.UploadRequest, uploadFunc storage.UploadFinishedFunc) (*provider.ResourceInfo, error) { + c, err := d.client(ctx) + if err != nil { + return nil, err + } + parentID := req.Ref.GetResourceId().GetOpaqueId() + filename := req.Ref.GetPath() + fi, err := uploadFile(c, parentID, filename, req.Length, req.Body, d.cfg.ChunkSize) + if err != nil { + return nil, err + } + ri := fileInfoToResourceInfo(fi) + if uploadFunc != nil { + u, ok := ctxpkg.ContextGetUser(ctx) + if !ok { + return nil, errtypes.PermissionDenied("kiteworks: no user in context") + } + uploadFunc(u.GetId(), u.GetId(), req.Ref) + } + return ri, nil +} +func (d *Driver) ListRevisions(_ context.Context, _ *provider.Reference) ([]*provider.FileVersion, error) { + return nil, errtypes.NotSupported("kiteworks: ListRevisions") +} +func (d *Driver) DownloadRevision(_ context.Context, _ *provider.Reference, _ string, _ func(*provider.ResourceInfo) bool) (*provider.ResourceInfo, io.ReadCloser, error) { + return nil, nil, errtypes.NotSupported("kiteworks: DownloadRevision") +} +func (d *Driver) RestoreRevision(_ context.Context, _ *provider.Reference, _ string) error { + return errtypes.NotSupported("kiteworks: RestoreRevision") +} +func (d *Driver) ListRecycle(_ context.Context, _ *provider.Reference, _, _ string) ([]*provider.RecycleItem, error) { + return nil, errtypes.NotSupported("kiteworks: ListRecycle") +} +func (d *Driver) RestoreRecycleItem(_ context.Context, _ *provider.Reference, _, _ string, _ *provider.Reference) error { + return errtypes.NotSupported("kiteworks: RestoreRecycleItem") +} +func (d *Driver) PurgeRecycleItem(_ context.Context, _ *provider.Reference, _, _ string) error { + return errtypes.NotSupported("kiteworks: PurgeRecycleItem") +} +func (d *Driver) EmptyRecycle(_ context.Context, _ *provider.Reference) error { + return errtypes.NotSupported("kiteworks: EmptyRecycle") +} +func (d *Driver) AddGrant(_ context.Context, _ *provider.Reference, _ *provider.Grant) error { + return errtypes.NotSupported("kiteworks: AddGrant — permission mapping not yet implemented") +} +func (d *Driver) DenyGrant(_ context.Context, _ *provider.Reference, _ *provider.Grantee) error { + return errtypes.NotSupported("kiteworks: DenyGrant") +} +func (d *Driver) RemoveGrant(_ context.Context, _ *provider.Reference, _ *provider.Grant) error { + return errtypes.NotSupported("kiteworks: RemoveGrant — permission mapping not yet implemented") +} +func (d *Driver) UpdateGrant(_ context.Context, _ *provider.Reference, _ *provider.Grant) error { + return errtypes.NotSupported("kiteworks: UpdateGrant — permission mapping not yet implemented") +} +func (d *Driver) ListGrants(ctx context.Context, ref *provider.Reference) ([]*provider.Grant, error) { + c, err := d.client(ctx) + if err != nil { + return nil, err + } + id := ref.GetResourceId().GetOpaqueId() + if id == "" { + return nil, errtypes.NotFound("kiteworks: reference has no id") + } + fi, err := c.GetFolder(id) + if err != nil { + if ce, ok := err.(*ClientError); ok && ce.StatusCode == 404 { + fi, err = c.GetFile(id) + if err != nil { + return nil, err + } + } else { + return nil, err + } + } + var grants []*provider.Grant + for _, perm := range fi.Permissions { + if !perm.Allowed { + continue + } + grants = append(grants, &provider.Grant{ + Grantee: &provider.Grantee{ + Type: provider.GranteeType_GRANTEE_TYPE_USER, + Id: &provider.Grantee_UserId{ + UserId: &userpb.UserId{OpaqueId: perm.Name}, + }, + }, + Permissions: &provider.ResourcePermissions{ + GetPath: true, + InitiateFileDownload: true, + InitiateFileUpload: true, + ListContainer: true, + Stat: true, + }, + }) + } + return grants, nil +} +func (d *Driver) SetArbitraryMetadata(_ context.Context, _ *provider.Reference, _ *provider.ArbitraryMetadata) error { + return errtypes.NotSupported("kiteworks: SetArbitraryMetadata") +} +func (d *Driver) UnsetArbitraryMetadata(_ context.Context, _ *provider.Reference, _ []string) error { + return errtypes.NotSupported("kiteworks: UnsetArbitraryMetadata") +} +func (d *Driver) GetLock(_ context.Context, _ *provider.Reference) (*provider.Lock, error) { + return nil, errtypes.NotSupported("kiteworks: GetLock") +} +func (d *Driver) SetLock(_ context.Context, _ *provider.Reference, _ *provider.Lock) error { + return errtypes.NotSupported("kiteworks: SetLock") +} +func (d *Driver) RefreshLock(_ context.Context, _ *provider.Reference, _ *provider.Lock, _ string) error { + return errtypes.NotSupported("kiteworks: RefreshLock") +} +func (d *Driver) Unlock(_ context.Context, _ *provider.Reference, _ *provider.Lock) error { + return errtypes.NotSupported("kiteworks: Unlock") +} diff --git a/pkg/storage/fs/kiteworks/kiteworks_test.go b/pkg/storage/fs/kiteworks/kiteworks_test.go new file mode 100644 index 00000000000..3d3764fd088 --- /dev/null +++ b/pkg/storage/fs/kiteworks/kiteworks_test.go @@ -0,0 +1,14 @@ +// vendor/github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks/kiteworks_test.go +package kiteworks_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestKiteworks(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Kiteworks Storage Driver Suite") +} diff --git a/pkg/storage/fs/kiteworks/mock_server_test.go b/pkg/storage/fs/kiteworks/mock_server_test.go new file mode 100644 index 00000000000..cf67ac1db5a --- /dev/null +++ b/pkg/storage/fs/kiteworks/mock_server_test.go @@ -0,0 +1,62 @@ +// vendor/github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks/mock_server_test.go +package kiteworks_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + + . "github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks" +) + +func newMockServer() *httptest.Server { + mux := http.NewServeMux() + + mux.HandleFunc("/rest/users/me", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(User{ + ID: "u1", + Name: "Alice", + Email: "alice@example.com", + Quota: QuotaInfo{Allowed: 10737418240, Used: 1073741824}, + }) + }) + + mux.HandleFunc("/rest/folders/top", func(w http.ResponseWriter, r *http.Request) { + isShared := false + parentID := "0" + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []FileInfo{ + {ID: "f1", Name: "MyFiles", Type: "d", IsShared: &isShared}, + {ID: "f2", Name: "SharedFolder", Type: "d", IsShared: func() *bool { b := true; return &b }(), ParentID: &parentID}, + }, + }) + }) + + mux.HandleFunc("/rest/folders/f1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + isShared := false + json.NewEncoder(w).Encode(FileInfo{ID: "f1", Name: "MyFiles", Type: "d", IsShared: &isShared}) + }) + + mux.HandleFunc("/rest/folders/f1/children", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(DirectoryInfo{ + Files: []FileInfo{{ID: "file1", Name: "hello.txt", Type: "f", Size: 5}}, + Folders: []FileInfo{}, + }) + }) + + mux.HandleFunc("/rest/files/file1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(FileInfo{ID: "file1", Name: "hello.txt", Type: "f", Size: 5}) + }) + + mux.HandleFunc("/rest/files/file1/content", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("hello")) + }) + + return httptest.NewServer(mux) +} diff --git a/pkg/storage/fs/kiteworks/types.go b/pkg/storage/fs/kiteworks/types.go new file mode 100644 index 00000000000..1bef9b877d8 --- /dev/null +++ b/pkg/storage/fs/kiteworks/types.go @@ -0,0 +1,133 @@ +// vendor/github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks/types.go +package kiteworks + +import ( + "strings" + "time" +) + +// Time is a custom time type that handles Kiteworks date format +type Time struct { + time.Time +} + +func (t *Time) UnmarshalJSON(data []byte) error { + s := strings.Trim(string(data), `"`) + if s == "null" || s == "" { + return nil + } + parsed, err := time.Parse("2006-01-02T15:04:05+0000", s) + if err != nil { + parsed, err = time.Parse(time.RFC3339, s) + if err != nil { + return err + } + } + t.Time = parsed + return nil +} + +// FileFingerPrint holds a single hash entry +type FileFingerPrint struct { + Algo string `json:"algo"` + Hash string `json:"hash"` +} + +// FileFingerPrints holds all checksums for a file +type FileFingerPrints struct { + FingerPrints []FileFingerPrint `json:"fingerprints"` +} + +// Permission represents a Kiteworks permission entry +type Permission struct { + ID int `json:"id"` + Name string `json:"name"` + Allowed bool `json:"allowed"` +} + +// FileInfo is the metadata for a Kiteworks file or folder +type FileInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` // "d" = directory, "f" = file + Size int64 `json:"size"` + Modified *Time `json:"modified"` + Created *Time `json:"created"` + ParentID *string `json:"parentId"` + IsShared *bool `json:"shared"` + Permissions []Permission `json:"currentUserPermissions"` + FingerPrints *FileFingerPrints `json:"fileFingerprints"` +} + +// IsDir returns true if the FileInfo represents a directory +func (fi *FileInfo) IsDir() bool { + return fi.Type == "d" +} + +// IsSharedWithUser returns true when this top-level folder is a received share +func (fi *FileInfo) IsSharedWithUser() bool { + if fi == nil || fi.IsShared == nil || !*fi.IsShared { + return false + } + if fi.ParentID == nil { + return true + } + return *fi.ParentID == "0" +} + +// DirectoryInfo wraps a listing of files and folders +type DirectoryInfo struct { + Files []FileInfo `json:"files"` + Folders []FileInfo `json:"folders"` +} + +// CreateDir is the request body for creating a folder +type CreateDir struct { + Name string `json:"name"` + Syncable bool `json:"syncable"` +} + +// InitializeUpload is the request body for initiating a chunked upload +type InitializeUpload struct { + Filename string `json:"filename"` + TotalChunks int `json:"totalChunks"` + TotalFileSize int64 `json:"totalFileSize"` +} + +// UploadResult is the response from initiating an upload +type UploadResult struct { + ID string `json:"id"` + URI string `json:"uri"` +} + +// FileCopyMove is the request body for move/copy operations +type FileCopyMove struct { + DestFolderID string `json:"destFolderId"` + Replace bool `json:"replace"` +} + +// FileUpdateRequest is the request body for renaming a file +type FileUpdateRequest struct { + Name string `json:"name"` + Replace bool `json:"replace"` +} + +// FolderUpdateRequest is the request body for renaming a folder +type FolderUpdateRequest struct { + Name string `json:"name"` +} + +// QuotaInfo holds user storage quota information +type QuotaInfo struct { + Allowed int64 `json:"storageQuota"` + Used int64 `json:"storageUsed"` +} + +// User holds Kiteworks user information +type User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Quota QuotaInfo `json:"quota"` +} diff --git a/pkg/storage/fs/kiteworks/upload.go b/pkg/storage/fs/kiteworks/upload.go new file mode 100644 index 00000000000..60027c06caf --- /dev/null +++ b/pkg/storage/fs/kiteworks/upload.go @@ -0,0 +1,42 @@ +// vendor/github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks/upload.go +package kiteworks + +import ( + "io" + "math" +) + +const defaultChunkSize = 5 * 1024 * 1024 // 5 MB + +// uploadFile performs a chunked upload of r into parentFolderID. +// chunkSize of 0 uses defaultChunkSize. +func uploadFile(c *Client, parentFolderID, filename string, size int64, r io.Reader, chunkSize int64) (*FileInfo, error) { + if chunkSize <= 0 { + chunkSize = defaultChunkSize + } + + numChunks := 1 + if size > 0 { + numChunks = int(math.Ceil(float64(size) / float64(chunkSize))) + } + + result, err := c.InitiateUpload(parentFolderID, filename, size, numChunks) + if err != nil { + return nil, err + } + + buf := make([]byte, chunkSize) + var fi *FileInfo + for i := 0; i < numChunks; i++ { + n, err := io.ReadFull(r, buf) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return nil, err + } + isLast := i == numChunks-1 + fi, err = c.UploadChunk(result.URI, filename, buf[:n], i, isLast) + if err != nil { + return nil, err + } + } + return fi, nil +} diff --git a/pkg/storage/fs/loader/loader.go b/pkg/storage/fs/loader/loader.go index 0d8f5457c30..1aae9225f57 100644 --- a/pkg/storage/fs/loader/loader.go +++ b/pkg/storage/fs/loader/loader.go @@ -29,6 +29,7 @@ import ( _ "github.com/owncloud/reva/v2/pkg/storage/fs/hello" _ "github.com/owncloud/reva/v2/pkg/storage/fs/local" _ "github.com/owncloud/reva/v2/pkg/storage/fs/localhome" + _ "github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks" _ "github.com/owncloud/reva/v2/pkg/storage/fs/nextcloud" _ "github.com/owncloud/reva/v2/pkg/storage/fs/ocis" _ "github.com/owncloud/reva/v2/pkg/storage/fs/owncloudsql"