aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMichael Tews <michael@tews.dev>2026-02-28 21:31:04 +0100
committerMichael Tews <michael@tews.dev>2026-03-12 15:35:53 +0100
commit2673b7d003e853bf7bc7ffc4ba829d9d9d4e4b15 (patch)
tree1f043611001f0f795b37ca9015eb8aff932d9245
parentce94f2d69a5f1aab1fc8fc2947f0a6cfd81bb4d1 (diff)
feat: write support
Signed-off-by: Michael Tews <michael@tews.dev>
-rw-r--r--.gitignore1
-rw-r--r--backend/studip/directory.go45
-rw-r--r--backend/studip/filetree.go248
-rw-r--r--backend/studip/fs.go1201
-rw-r--r--backend/studip/object.go388
-rw-r--r--backend/studip/requests.go577
-rw-r--r--backend/studip/responses.go86
-rw-r--r--backend/studip/stack.go41
-rw-r--r--backend/studip/studip.go725
-rw-r--r--go.mod2
10 files changed, 2669 insertions, 645 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..567609b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+build/
diff --git a/backend/studip/directory.go b/backend/studip/directory.go
new file mode 100644
index 0000000..cd4c6c2
--- /dev/null
+++ b/backend/studip/directory.go
@@ -0,0 +1,45 @@
+package studip
+
+import (
+ "context"
+ "time"
+
+ "github.com/rclone/rclone/fs"
+)
+
+type Directory struct {
+ fs *Fs
+ id string
+ name string
+ items int64
+ modTime time.Time
+ remote string
+}
+
+func (dir *Directory) Fs() fs.Info {
+ return dir.fs
+}
+
+func (dir *Directory) ID() string {
+ return dir.id
+}
+
+func (dir *Directory) Items() int64 {
+ return dir.items
+}
+
+func (dir *Directory) String() string {
+ return dir.name
+}
+
+func (dir *Directory) ModTime(context.Context) time.Time {
+ return dir.modTime
+}
+
+func (dir *Directory) Remote() string {
+ return dir.remote
+}
+
+func (dir *Directory) Size() int64 {
+ return -1
+}
diff --git a/backend/studip/filetree.go b/backend/studip/filetree.go
new file mode 100644
index 0000000..8f7c5c7
--- /dev/null
+++ b/backend/studip/filetree.go
@@ -0,0 +1,248 @@
+package studip
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/rclone/rclone/fs"
+)
+
+type Node struct {
+ Children []*Node
+ Parent *Node
+ Name string
+ Path string
+ ID string
+ IsReadable bool
+ IsWritable bool
+ IsDownloadable bool
+ IsEditable bool
+ IsSubfolderAllowed bool
+ IsDir bool
+ ChDate time.Time
+ Size int64
+ ContentType string
+}
+
+func (n *Node) String() string {
+ if n == nil {
+ return "<nil Node>"
+ }
+
+ parentPath := "<nil>"
+ if n.Parent != nil {
+ parentPath = n.Parent.Path
+ }
+
+ return fmt.Sprintf(
+ "Node{name=%q path=%q id=%q isDir=%t size=%d children=%d readable=%t writable=%t editable=%t downloadable=%t subfolderAllowed=%t contentType=%q parentPath=%q}",
+ n.Name,
+ n.Path,
+ n.ID,
+ n.IsDir,
+ n.Size,
+ len(n.Children),
+ n.IsReadable,
+ n.IsWritable,
+ n.IsEditable,
+ n.IsDownloadable,
+ n.IsSubfolderAllowed,
+ n.ContentType,
+ parentPath,
+ )
+}
+
+type FileTree struct {
+ root *Node
+ // This is the root from rclone's perspective
+ // most functions should use the relativeRoot
+ relativeRoot *Node
+}
+
+func (ft *FileTree) String() string {
+ if ft == nil {
+ return "<nil FileTree>"
+ }
+
+ rootPath := "<nil>"
+ rootID := "<nil>"
+ rootChildren := -1
+ if ft.root != nil {
+ rootPath = ft.root.Path
+ rootID = ft.root.ID
+ rootChildren = len(ft.root.Children)
+ }
+
+ relativeRootPath := "<nil>"
+ relativeRootID := "<nil>"
+ relativeRootChildren := -1
+ if ft.relativeRoot != nil {
+ relativeRootPath = ft.relativeRoot.Path
+ relativeRootID = ft.relativeRoot.ID
+ relativeRootChildren = len(ft.relativeRoot.Children)
+ }
+
+ return fmt.Sprintf(
+ "FileTree{rootPath=%q rootID=%q rootChildren=%d relativeRootPath=%q relativeRootID=%q relativeRootChildren=%d sameRoot=%t}",
+ rootPath,
+ rootID,
+ rootChildren,
+ relativeRootPath,
+ relativeRootID,
+ relativeRootChildren,
+ ft.root == ft.relativeRoot,
+ )
+}
+
+func (root *Node) GetNodeAtPath(path string) *Node {
+ Assert(root != nil, fmt.Sprintf("root must be not nil; root=%q", root))
+
+ pathSplit := splitPath(path)
+ if len(pathSplit) == 0 {
+ return root
+ }
+
+ currentNode := root
+
+ for len(pathSplit) > 0 {
+ if pathSplit[0] == "." {
+ pathSplit = pathSplit[1:]
+ continue
+ }
+
+ if pathSplit[0] == "" {
+ pathSplit = pathSplit[1:]
+ continue
+ }
+
+ found := false
+ for _, children := range currentNode.Children {
+ if strings.EqualFold(children.Name, pathSplit[0]) {
+ currentNode = children
+ pathSplit = pathSplit[1:]
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ return nil
+ }
+ }
+
+ return currentNode
+}
+
+func (ft *FileTree) ListEntries(fsys *Fs, dir string) (entries fs.DirEntries, err error) {
+ Assert(ft != nil, fmt.Sprintf("ft must be not nil; ft=%q", ft))
+ Assert(fsys != nil, fmt.Sprintf("fsys must be not nil; fsys=%q", fsys))
+
+ if ft.relativeRoot == nil {
+ return nil, fs.ErrorDirNotFound
+ }
+
+ if !ft.relativeRoot.IsDir {
+ return nil, fs.ErrorIsFile
+ }
+
+ node := ft.relativeRoot.GetNodeAtPath(dir)
+ if node == nil {
+ return nil, fs.ErrorDirNotFound
+ }
+
+ if !node.IsDir {
+ return nil, fs.ErrorIsFile
+ }
+
+ for _, child := range node.Children {
+ Assert(
+ child != nil,
+ fmt.Sprintf(
+ "child node must be not nil; dir=%q child=%q",
+ dir, child,
+ ),
+ )
+
+ Assert(
+ child.ID != "",
+ fmt.Sprintf(
+ "child node id must be not empty; dir=%q childID=%q",
+ dir, child.ID,
+ ),
+ )
+ Assert(
+ child.Name != "",
+ fmt.Sprintf(
+ "child node name must be not empty; dir=%q childID=%q",
+ dir, child.ID,
+ ),
+ )
+
+ Assert(
+ !child.ChDate.IsZero(),
+ fmt.Sprintf(
+ "child node chdate must be not zero; dir=%q chdate=%q",
+ dir, child.ChDate,
+ ),
+ )
+
+ if child.IsDir {
+ Assert(
+ child.Size == -1,
+ fmt.Sprintf(
+ "child node size must be -1; dir=%q child=%q id=%q got=%d",
+ dir, child.Name, child.ID, child.Size,
+ ),
+ )
+
+ directory := new(Directory)
+ directory.fs = fsys
+ directory.remote = joinPath(dir, child.Name)
+ directory.id = child.ID
+ directory.items = int64(len(child.Children))
+ directory.name = child.Name
+ directory.modTime = child.ChDate
+
+ entries = append(entries, directory)
+ } else {
+
+ Assert(
+ child.Size >= 0,
+ fmt.Sprintf(
+ "file node size must be >= 0; dir=%q child=%q id=%q got=%d",
+ dir, child.Name, child.ID, child.Size,
+ ),
+ )
+
+ Assert(
+ child.ContentType != "",
+ fmt.Sprintf(
+ "file node contenttype must be not empty; dir=%q child=%q id=%q contenttype=%q",
+ dir, child.Name, child.ID, child.ContentType,
+ ),
+ )
+
+ object := new(Object)
+ object.fs = fsys
+ object.remote = joinPath(dir, child.Name)
+ object.id = child.ID
+ object.size = child.Size
+ object.isReadable = child.IsReadable
+ object.isEditable = child.IsEditable
+ object.isWritable = child.IsWritable
+ object.IsDownloadable = child.IsDownloadable
+ object.contentType = child.ContentType
+ object.modTime = child.ChDate
+
+ entries = append(entries, object)
+ }
+ }
+
+ sort.Slice(entries, func(i, j int) bool {
+ return entries.Less(i, j)
+ })
+
+ return entries, nil
+}
diff --git a/backend/studip/fs.go b/backend/studip/fs.go
new file mode 100644
index 0000000..70b9dac
--- /dev/null
+++ b/backend/studip/fs.go
@@ -0,0 +1,1201 @@
+package studip
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "path"
+ "slices"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/lib/rest"
+
+ "github.com/rclone/rclone/fs/config/configmap"
+ "github.com/rclone/rclone/fs/config/configstruct"
+ "github.com/rclone/rclone/fs/config/obscure"
+ "github.com/rclone/rclone/fs/fshttp"
+)
+
+type Fs struct {
+ name string
+ opt *Options
+ client *rest.Client
+ // This is the path that rclone uses as the root
+ relativeRootPath string
+ ft FileTree
+ mu sync.Mutex
+}
+
+func NewFs(
+ ctx context.Context,
+ name,
+ rootPath string,
+ m configmap.Mapper,
+) (fs.Fs, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ fs.Debugf(name, "initializing studip backend for root %q", rootPath)
+
+ opt := new(Options)
+ if err := configstruct.Set(m, opt); err != nil {
+ fs.Debugf(name, "failed to parse backend config: %v", err)
+ return nil, err
+ }
+
+ fs.Debugf(name, "loaded backend config for course_id=%q base_url=%q", opt.CourseID, opt.BaseURL)
+
+ if opt.CourseID == "" {
+ return nil, errors.New("course_id is required")
+ }
+
+ base, err := url.Parse(opt.BaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("invalid base_url: %w", err)
+ }
+
+ var httpClient *rest.Client
+ {
+ c := fshttp.NewClient(context.Background())
+ httpClient = rest.NewClient(c)
+ }
+
+ httpClient.SetRoot(base.String())
+ httpClient.SetHeader("Accept", "application/vnd.api+json")
+ httpClient.SetUserPass(opt.Username, obscure.MustReveal(opt.Password))
+ httpClient.SetErrorHandler(func(resp *http.Response) error {
+ if resp == nil {
+ return fmt.Errorf("http error: nil response")
+ }
+
+ var b strings.Builder
+ b.WriteString("====== HTTP ERROR ======\n")
+
+ req := resp.Request
+
+ // ---- Request ----
+ if req != nil {
+ b.WriteString(fmt.Sprintf("Request: %s %s\n", req.Method, req.URL.String()))
+
+ b.WriteString("Request Headers:\n")
+ for k, v := range req.Header {
+ b.WriteString(fmt.Sprintf(" %s: %v\n", k, v))
+ }
+
+ if req.Body != nil {
+ defer req.Body.Close()
+
+ reqBody, err := io.ReadAll(req.Body)
+ if err == nil {
+ b.WriteString("Request Body:\n")
+ b.Write(reqBody)
+ b.WriteString("\n")
+
+ // restore body
+ req.Body = io.NopCloser(bytes.NewBuffer(reqBody))
+ }
+ }
+ }
+
+ // ---- Response ----
+ b.WriteString(fmt.Sprintf("Response Status: %s\n", resp.Status))
+
+ b.WriteString("Response Headers:\n")
+ for k, v := range resp.Header {
+ b.WriteString(fmt.Sprintf(" %s: %v\n", k, v))
+ }
+
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err == nil {
+ b.WriteString("Response Body:\n")
+ b.Write(respBody)
+ b.WriteString("\n")
+
+ // restore body
+ resp.Body = io.NopCloser(bytes.NewBuffer(respBody))
+ }
+
+ b.WriteString("========================")
+
+ return fmt.Errorf("%s", b.String())
+ })
+ fs.Debugf(name, "configured HTTP client root=%q username=%q", base.String(), opt.Username)
+
+ f := &Fs{
+ name: name,
+ opt: opt,
+ client: httpClient,
+ relativeRootPath: rootPath,
+ ft: FileTree{},
+ }
+
+ fs.Debugf(f, "testing Stud.IP connection")
+
+ if err := f.TestConnection(ctx); err != nil {
+ fs.Debugf(f, "connection test failed: %v", err)
+ return nil, err
+ }
+
+ fs.Debugf(f, "connection test successful")
+
+ fs.Debugf(f, "building course file tree")
+ f.ft.root, err = f.GetCourseFileTree(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ fs.Debugf(f, "course file tree initialized")
+
+ if rootPath == "" {
+ f.ft.relativeRoot = f.ft.root
+ return f, nil
+ }
+
+ f.ft.relativeRoot = f.ft.root.GetNodeAtPath(rootPath)
+ if f.ft.relativeRoot == nil {
+ fs.Debugf(f, "relative root %q not found in file tree", rootPath)
+ } else {
+ fs.Debugf(f, "relative root resolved path=%q id=%q", f.relativeRootPath, f.ft.relativeRoot.ID)
+
+ if !f.ft.relativeRoot.IsDir {
+ f.ft.relativeRoot = f.ft.relativeRoot.Parent
+ f.relativeRootPath = dirPath(f.relativeRootPath)
+ return f, fs.ErrorIsFile
+ }
+ }
+
+ return f, nil
+}
+
+func (f *Fs) GetCourseFileTree(
+ ctx context.Context,
+) (*Node, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ rootFolder, err := f.RetrieveRootFolder(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ rootNode := new(Node)
+ rootNode.Name = "root"
+ rootNode.Path = ""
+ rootNode.ID = rootFolder.ID
+ rootNode.IsReadable = rootFolder.Attributes.IsReadable
+ rootNode.IsWritable = rootFolder.Attributes.IsWritable
+ rootNode.IsEditable = rootFolder.Attributes.IsEditable
+ rootNode.IsSubfolderAllowed = rootFolder.Attributes.IsSubfolderAllowed
+ rootNode.IsDir = true
+ rootNode.ChDate = rootFolder.Attributes.Chdate
+
+ err = f.FillFolderNode(ctx, rootNode, rootNode.Path)
+ if err != nil {
+ return nil, err
+ }
+
+ return rootNode, nil
+}
+
+func (f *Fs) FillFolderNode(
+ ctx context.Context,
+ folderNode *Node,
+ path string,
+) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ Assert(
+ folderNode != nil,
+ fmt.Sprintf(
+ "folderNode must be not nil; folderNode=%q",
+ folderNode,
+ ),
+ )
+
+ if !folderNode.IsDir {
+ return fs.ErrorIsFile
+ }
+
+ if !folderNode.IsReadable {
+ return fs.ErrorPermissionDenied
+ }
+
+ folders, err := f.studIPGetFoldersOfFolder(ctx, folderNode.ID)
+ if err != nil {
+ return err
+ }
+
+ folderNode.Children = slices.Grow(folderNode.Children, len(folders.Data))
+
+ for _, folder := range folders.Data {
+ childrenNode := new(Node)
+ childrenNode.IsWritable = folder.Attributes.IsWritable
+ childrenNode.IsReadable = folder.Attributes.IsReadable
+ childrenNode.IsEditable = folder.Attributes.IsEditable
+ childrenNode.IsSubfolderAllowed = folder.Attributes.IsSubfolderAllowed
+ childrenNode.Parent = folderNode
+ childrenNode.ID = folder.ID
+ childrenNode.IsDir = true
+ childrenNode.Name = f.opt.Enc.ToStandardName(folder.Attributes.Name)
+ childrenNode.ChDate = folder.Attributes.Chdate
+ childrenNode.Path = joinPath(path, childrenNode.Name)
+ childrenNode.Size = -1
+
+ folderNode.Children = append(folderNode.Children, childrenNode)
+ }
+
+ {
+ errChan := make(chan error)
+ length := len(folderNode.Children)
+ {
+ for _, childrenNode := range folderNode.Children {
+ if childrenNode.IsReadable {
+ go func() {
+ errChan <- f.FillFolderNode(ctx, childrenNode, joinPath(path, childrenNode.Name))
+ }()
+ }
+ }
+ }
+
+ for range length {
+ err := <-errChan
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ files, err := f.RetrieveFilesOfFolder(ctx, folderNode.ID)
+ if err != nil {
+ return err
+ }
+
+ folderNode.Children = slices.Grow(folderNode.Children, len(files.Data))
+
+ for _, file := range files.Data {
+ childrenNode := new(Node)
+ childrenNode.IsDownloadable = file.Attributes.IsDownloadable
+ childrenNode.IsWritable = file.Attributes.IsWritable
+ childrenNode.IsReadable = file.Attributes.IsReadable
+ childrenNode.IsEditable = file.Attributes.IsEditable
+ childrenNode.ID = file.ID
+ childrenNode.IsDir = false
+ childrenNode.Parent = folderNode
+ childrenNode.Name = f.opt.Enc.ToStandardName(file.Attributes.Name)
+ childrenNode.ChDate = file.Attributes.Chdate
+ childrenNode.Size = file.Attributes.Filesize
+ childrenNode.Path = joinPath(path, childrenNode.Name)
+ childrenNode.ContentType = file.Attributes.MimeType
+
+ folderNode.Children = append(folderNode.Children, childrenNode)
+ }
+
+ return nil
+}
+
+func (f *Fs) RetrieveFilesOfFolder(
+ ctx context.Context,
+ folderID string,
+) (*StudIPFiles, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ return f.studIPGetFilesOfFolder(ctx, folderID)
+}
+
+func (f *Fs) RetrieveRootFolder(
+ ctx context.Context,
+) (folder StudIPFoldersData, err error) {
+ if ctx.Err() != nil {
+ return folder, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ responseJSON, err := f.studIPGetCourseFolders(ctx)
+ if err != nil {
+ return folder, err
+ }
+
+ index := slices.IndexFunc(responseJSON.Data,
+ func(e StudIPFoldersData) bool { return e.Attributes.FolderType == "RootFolder" },
+ )
+
+ if index == -1 {
+ return folder, errors.New("response doesn't contain a RootFolder")
+ }
+
+ folder = responseJSON.Data[index]
+
+ return folder, nil
+}
+
+func (f *Fs) Put(
+ ctx context.Context,
+ in io.Reader,
+ src fs.ObjectInfo,
+ options ...fs.OpenOption,
+) (fs.Object, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ Assert(
+ src != nil,
+ fmt.Sprintf(
+ "src must be not nil; src=%q",
+ src,
+ ),
+ )
+
+ Assert(
+ in != nil,
+ fmt.Sprintf(
+ "in must be not nil; in=%q",
+ in,
+ ),
+ )
+
+ remotePath := src.Remote()
+ if remotePath == "" {
+ return nil, fmt.Errorf("invalid remote path %q", remotePath)
+ }
+
+ existingAny, err := f.NewObject(ctx, remotePath)
+ if err == nil {
+ existing, ok := existingAny.(*Object)
+ if !ok {
+ return nil, fmt.Errorf("unexpected object type %T for remote %q", existingAny, remotePath)
+ }
+ if existing.id == "" {
+ return nil, fmt.Errorf("existing object has empty id for remote %q", remotePath)
+ }
+ if !existing.isEditable || !existing.isWritable {
+ return nil, fs.ErrorPermissionDenied
+ }
+
+ location, err := f.studIPUpdateFileContent(
+ ctx,
+ existing.id,
+ in,
+ basePath(remotePath),
+ src.Size(),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ existing.id, err = fileRefIDFromLocation(location)
+ if err != nil {
+ return nil, err
+ }
+
+ existing.size = src.Size()
+ existing.modTime = src.ModTime(ctx)
+ existing.contentType = fs.MimeType(ctx, src)
+ if f.ft.relativeRoot != nil {
+ if node := f.ft.relativeRoot.GetNodeAtPath(remotePath); node != nil && !node.IsDir {
+ node.ID = existing.id
+ node.Size = existing.size
+ node.ChDate = existing.modTime
+ node.ContentType = existing.contentType
+ }
+ }
+
+ err = existing.SetTermsOfUse(ctx, f.opt.License)
+ if err != nil {
+ return nil, err
+ }
+
+ fs.Debugf(
+ f,
+ "Put: updated existing object remote=%q id=%q location=%q",
+ remotePath,
+ existing.id,
+ location,
+ )
+ return existing, nil
+ }
+ if !errors.Is(err, fs.ErrorObjectNotFound) {
+ return nil, err
+ }
+
+ object := &Object{
+ fs: f,
+ remote: remotePath,
+ size: src.Size(),
+ isReadable: true,
+ isEditable: true,
+ isWritable: true,
+ IsDownloadable: true,
+ modTime: src.ModTime(ctx),
+ contentType: fs.MimeType(ctx, src),
+ }
+
+ parentDir := dirPath(remotePath)
+ cleanRoot := cleanPath(f.relativeRootPath)
+ parentDirForCreation := parentDir
+ if f.ft.relativeRoot == nil {
+ parentDirForCreation = joinPath(cleanRoot, parentDir)
+ }
+
+ directoryNode, err := f.CreateParentDirectories(ctx, parentDirForCreation)
+ if err != nil {
+ return nil, err
+ }
+ if directoryNode == nil {
+ return nil, fmt.Errorf("failed to resolve parent directory for %q", remotePath)
+ }
+ if !directoryNode.IsDir {
+ return nil, fmt.Errorf("resolved parent node is not a directory: %q", directoryNode.Path)
+ }
+ if directoryNode.ID == "" {
+ return nil, fmt.Errorf("resolved parent directory has empty id for %q", remotePath)
+ }
+
+ location, err := f.studIPCreateFileContent(
+ ctx,
+ directoryNode.ID,
+ in,
+ basePath(remotePath),
+ src.Size(),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ object.id, err = fileRefIDFromLocation(location)
+ if err != nil {
+ return nil, err
+ }
+
+ err = object.SetTermsOfUse(ctx, f.opt.License)
+ if err != nil {
+ return nil, err
+ }
+
+ filename := basePath(remotePath)
+ updatedNode := false
+ for _, child := range directoryNode.Children {
+ if child == nil || child.IsDir || !strings.EqualFold(child.Name, filename) {
+ continue
+ }
+
+ child.ID = object.id
+ child.IsReadable = object.isReadable
+ child.IsWritable = object.isWritable
+ child.IsEditable = object.isEditable
+ child.IsDownloadable = object.IsDownloadable
+ child.IsDir = false
+ child.ChDate = object.modTime
+ child.Size = object.size
+ child.ContentType = object.contentType
+ updatedNode = true
+ break
+ }
+
+ if !updatedNode {
+ directoryNode.Children = append(directoryNode.Children, &Node{
+ Parent: directoryNode,
+ Name: filename,
+ Path: joinPath(directoryNode.Path, filename),
+ ID: object.id,
+ IsReadable: object.isReadable,
+ IsWritable: object.isWritable,
+ IsDownloadable: object.IsDownloadable,
+ IsEditable: object.isEditable,
+ IsDir: false,
+ ChDate: object.modTime,
+ Size: object.size,
+ ContentType: object.contentType,
+ })
+ }
+
+ return object, nil
+}
+
+func (f *Fs) Mkdir(ctx context.Context, dir string) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ fs.Infof(f, "Mkdir f.root=%q dir=%q", f.relativeRootPath, dir)
+
+ var parentNode *Node
+ var dirname string
+ var err error
+
+ // creating relativeRoot
+ if dir == "" {
+ if f.ft.relativeRoot == nil {
+ fs.Debugf(f, "Mkdir: rootNode nil, creating parent chain for %q", dirPath(f.relativeRootPath))
+ parentNode, err = f.CreateParentDirectories(ctx, dirPath(f.relativeRootPath))
+ if err != nil {
+ return err
+ }
+ } else {
+ return nil
+ }
+ dirname = basePath(f.relativeRootPath)
+ } else {
+ // creating dir inside relativeRoot
+ dirname = basePath(dir)
+ if f.ft.relativeRoot != nil {
+ parentNode = f.ft.relativeRoot.GetNodeAtPath(dirPath(dir))
+ }
+ if parentNode == nil {
+ fs.Debugf(f, "Mkdir: parent missing for %q, creating chain", dir)
+ parentNode, err = f.CreateParentDirectories(ctx, dirPath(dir))
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ if dirname == "" {
+ return fmt.Errorf("invalid directory name %q", dirname)
+ }
+
+ if parentNode == nil {
+ return fs.ErrorDirNotFound
+ }
+
+ if !parentNode.IsDir {
+ return fmt.Errorf("parent node is not a directory: %q", parentNode.Path)
+ }
+
+ if parentNode.ID == "" {
+ return fmt.Errorf("parent node has empty id: %q", parentNode.Path)
+ }
+
+ if !parentNode.IsSubfolderAllowed {
+ return fs.ErrorPermissionDenied
+ }
+
+ fs.Debugf(
+ f,
+ "Mkdir: resolved parent path=%q id=%q for dirname=%q",
+ parentNode.Path,
+ parentNode.ID,
+ dirname,
+ )
+
+ // is this needed?
+ if f.findDirectoryNodeByName(parentNode, dirname) != nil {
+ return nil
+ }
+
+ fs.Debugf(f, "Mkdir: creating directory %q under parent id=%q", dirname, parentNode.ID)
+ apiDirname := f.opt.Enc.FromStandardName(dirname)
+ if err := f.studIPMkDir(ctx, parentNode.ID, apiDirname); err != nil {
+ return err
+ }
+
+ createdDirectory, err := f.findDirectoryByName(ctx, parentNode.ID, dirname)
+ if err != nil {
+ return err
+ }
+
+ fs.Debugf(f, "Mkdir: created directory %q with id=%q", dirname, createdDirectory.ID)
+
+ createdDirectoryNode := &Node{
+ Parent: parentNode,
+ Name: dirname,
+ Path: joinPath(parentNode.Path, dirname),
+ ID: createdDirectory.ID,
+ IsReadable: createdDirectory.Attributes.IsReadable,
+ IsWritable: createdDirectory.Attributes.IsWritable,
+ IsEditable: createdDirectory.Attributes.IsEditable,
+ IsSubfolderAllowed: createdDirectory.Attributes.IsSubfolderAllowed,
+ IsDir: true,
+ ChDate: createdDirectory.Attributes.Chdate,
+ Size: -1,
+ }
+
+ // is this needed?
+ if f.findDirectoryNodeByName(parentNode, dirname) != nil {
+ return nil
+ }
+
+ parentNode.Children = append(parentNode.Children, createdDirectoryNode)
+
+ f.updateRelativeRootFromTree()
+
+ return nil
+}
+
+func (f *Fs) findDirectoryNodeByName(parentNode *Node, name string) *Node {
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ Assert(
+ parentNode != nil,
+ fmt.Sprintf(
+ "parentNode must be not nil; parentNode=%q",
+ parentNode,
+ ),
+ )
+
+ Assert(
+ name != "",
+ fmt.Sprintf(
+ "name must be not empty; name=%q",
+ name,
+ ),
+ )
+
+ for _, child := range parentNode.Children {
+ if child.IsDir && strings.EqualFold(child.Name, name) {
+ return child
+ }
+ }
+
+ return nil
+}
+
+func (f *Fs) findDirectoryByName(
+ ctx context.Context,
+ parentFolderID string,
+ name string,
+) (StudIPFoldersData, error) {
+ if ctx.Err() != nil {
+ return StudIPFoldersData{}, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ Assert(
+ parentFolderID != "",
+ fmt.Sprintf(
+ "parentFolderID must be not empty; parentFolderID=%q",
+ parentFolderID,
+ ),
+ )
+
+ Assert(
+ name != "",
+ fmt.Sprintf(
+ "name must be not empty; name=%q",
+ name,
+ ),
+ )
+
+ folders, err := f.studIPGetFoldersOfFolder(ctx, parentFolderID)
+ if err != nil {
+ return StudIPFoldersData{}, err
+ }
+
+ for _, folder := range folders.Data {
+ if strings.EqualFold(f.opt.Enc.ToStandardName(folder.Attributes.Name), name) {
+ return folder, nil
+ }
+ }
+
+ return StudIPFoldersData{}, fs.ErrorDirNotFound
+}
+
+// This is for creating the parent directories for a non existing directory
+func (f *Fs) CreateParentDirectories(
+ ctx context.Context,
+ dir string,
+) (*Node, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ var targetPath string
+ if f.ft.relativeRoot != nil {
+ targetPath = joinPath(f.relativeRootPath, dir)
+ } else {
+ targetPath = dir
+ }
+ fs.Debugf(f, "CreateParentDirectories: normalized targetPath=%q", targetPath)
+
+ targetNode := f.ft.root.GetNodeAtPath(targetPath)
+ if targetNode != nil {
+ if !targetNode.IsDir {
+ return nil, fmt.Errorf("target path is not a directory: %q", targetPath)
+ }
+ if targetNode.ID == "" {
+ return nil, fmt.Errorf("target directory has empty id: %q", targetPath)
+ }
+ fs.Debugf(
+ f,
+ "CreateParentDirectories: target already exists path=%q id=%q",
+ targetNode.Path,
+ targetNode.ID,
+ )
+ f.updateRelativeRootFromTree()
+ return targetNode, nil
+ }
+
+ stack := NewStack[string]()
+ currentPath := targetPath
+
+ for {
+ candidate := f.ft.root.GetNodeAtPath(currentPath)
+ if candidate != nil {
+ if !candidate.IsDir {
+ return nil, fmt.Errorf("existing path segment is not a directory: %q", currentPath)
+ }
+ if candidate.ID == "" {
+ return nil, fmt.Errorf("existing path segment has empty id: %q", currentPath)
+ }
+ if !candidate.IsWritable {
+ return nil, fs.ErrorPermissionDenied
+ }
+ targetNode = candidate
+ break
+ }
+
+ if currentPath == "" {
+ return nil, fs.ErrorDirNotFound
+ }
+
+ stack.Push(basePath(currentPath))
+ currentPath = dirPath(currentPath)
+ }
+ fs.Debugf(f, "CreateParentDirectories: creating %d missing segments", stack.Len())
+
+ for stack.Len() > 0 {
+ dirname, ok := stack.Pop()
+ Assert(ok, "stack.Pop() must return a value")
+ if dirname == "" {
+ return nil, fmt.Errorf("invalid directory segment %q", dirname)
+ }
+ if targetNode == nil || !targetNode.IsDir || targetNode.ID == "" {
+ return nil, fmt.Errorf("invalid parent node while creating %q", dirname)
+ }
+ fs.Debugf(
+ f,
+ "CreateParentDirectories: creating segment=%q under parent path=%q id=%q",
+ dirname,
+ targetNode.Path,
+ targetNode.ID,
+ )
+
+ apiDirname := f.opt.Enc.FromStandardName(dirname)
+ if err := f.studIPMkDir(ctx, targetNode.ID, apiDirname); err != nil {
+ return nil, err
+ }
+
+ createdDirectory, err := f.findDirectoryByName(ctx, targetNode.ID, dirname)
+ if err != nil {
+ return nil, err
+ }
+ if createdDirectory.ID == "" {
+ return nil, fmt.Errorf(
+ "created directory %q but failed to resolve id",
+ dirname,
+ )
+ }
+ fs.Debugf(f, "CreateParentDirectories: created segment=%q id=%q", dirname, createdDirectory.ID)
+
+ createdNode := &Node{
+ Parent: targetNode,
+ Name: dirname,
+ Path: joinPath(targetNode.Path, dirname),
+ ID: createdDirectory.ID,
+ IsReadable: createdDirectory.Attributes.IsReadable,
+ IsWritable: createdDirectory.Attributes.IsWritable,
+ IsEditable: createdDirectory.Attributes.IsEditable,
+ IsSubfolderAllowed: createdDirectory.Attributes.IsSubfolderAllowed,
+ IsDir: true,
+ ChDate: createdDirectory.Attributes.Chdate,
+ Size: -1,
+ }
+
+ targetNode.Children = append(targetNode.Children, createdNode)
+ targetNode = createdNode
+ }
+ fs.Debugf(
+ f,
+ "CreateParentDirectories: done path=%q id=%q",
+ targetNode.Path,
+ targetNode.ID,
+ )
+ f.updateRelativeRootFromTree()
+
+ return targetNode, nil
+}
+
+// updateRelativeRootFromTree resolves f.ft.relativeRoot after directories were created.
+// This is needed when the backend starts with a non-existent root path and that path is
+// created lazily during Put/Mkdir operations.
+func (f *Fs) updateRelativeRootFromTree() {
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ Assert(
+ f.ft.root != nil,
+ "f.ft.root must be not nil",
+ )
+
+ // f.ft.relativeRoot is set nothing todo here
+ if f.ft.relativeRoot != nil {
+ return
+ }
+
+ if f.relativeRootPath == "" {
+ f.ft.relativeRoot = f.ft.root
+ return
+ }
+
+ rootNode := f.ft.root.GetNodeAtPath(f.relativeRootPath)
+ if rootNode == nil || !rootNode.IsDir {
+ return
+ }
+
+ f.ft.relativeRoot = rootNode
+ fs.Debugf(f, "resolved relative root path=%q id=%q", rootNode.Path, rootNode.ID)
+}
+
+func (f *Fs) Rmdir(ctx context.Context, dir string) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ if f.ft.relativeRoot == nil {
+ return fs.ErrorDirNotFound
+ }
+
+ node := f.ft.relativeRoot.GetNodeAtPath(dir)
+ if node == nil {
+ return fs.ErrorDirNotFound
+ }
+
+ if !node.IsEditable {
+ return fs.ErrorPermissionDenied
+ }
+
+ // if Directory is root
+ if node.Parent == nil && node.Name == f.ft.root.Name && node.Path == f.ft.root.Path {
+ return fs.ErrorCantPurge
+ }
+
+ if len(node.Children) > 0 {
+ return fs.ErrorDirectoryNotEmpty
+ }
+
+ err := f.studIPDeleteFolder(ctx, node.ID)
+ if err != nil {
+ return err
+ }
+
+ // if the deleted node was the relativeRootPath we have to nil it
+ if f.ft.relativeRoot.ID == node.ID {
+ f.ft.relativeRoot = nil
+ }
+
+ index := slices.Index(node.Parent.Children, node)
+ if index >= 0 {
+ node.Parent.Children = slices.Delete(node.Parent.Children, index, index+1)
+ }
+
+ return nil
+}
+
+func (f *Fs) TestConnection(
+ ctx context.Context,
+) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ responseJSON, err := f.studIPGetCourse(ctx)
+ if err != nil {
+ return err
+ }
+
+ if responseJSON.Data.ID != f.opt.CourseID {
+ return fmt.Errorf("received courseID doesn't match"+
+ " configured courseID, received: %s, want: %s",
+ responseJSON.Data.ID, f.opt.CourseID)
+ }
+
+ return nil
+}
+
+func (f *Fs) Name() string { return f.name }
+
+func (f *Fs) Root() string { return f.relativeRootPath }
+func (f *Fs) String() string { return f.opt.BaseURL }
+func (f *Fs) Precision() time.Duration { return fs.ModTimeNotSupported }
+
+func (f *Fs) Hashes() hash.Set { return hash.Set(hash.None) }
+func (f *Fs) Features() *fs.Features {
+ return (&fs.Features{
+ CanHaveEmptyDirectories: true,
+ CaseInsensitive: true,
+ //ReadMimeType: true,
+ // TODO: Implement these
+ Copy: nil,
+ Move: nil,
+ DirMove: nil,
+ // implement this
+ Purge: nil,
+ }).
+ Fill(context.Background(), f)
+}
+
+func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ fs.Debugf(f, "NewObject: start remote=%q", remote)
+ if f == nil || f.ft.relativeRoot == nil {
+ fs.Debugf(f, "NewObject: relative root is not available")
+ return nil, fs.ErrorObjectNotFound
+ }
+
+ remote = cleanPath(remote)
+ fs.Debugf(f, "NewObject: normalized remote=%q", remote)
+ if remote == "" {
+ fs.Debugf(f, "NewObject: empty normalized path, returning not found")
+ return nil, fs.ErrorObjectNotFound
+ }
+
+ node := f.ft.relativeRoot.GetNodeAtPath(remote)
+ if node == nil || node.IsDir || node.ID == "" {
+ if node == nil {
+ fs.Debugf(f, "NewObject: node not found for %q", remote)
+ } else if node.IsDir {
+ fs.Debugf(f, "NewObject: path %q is a directory", remote)
+ } else {
+ fs.Debugf(f, "NewObject: node for %q has empty id", remote)
+ }
+ return nil, fs.ErrorObjectNotFound
+ }
+
+ object := &Object{
+ fs: f,
+ remote: remote,
+ id: node.ID,
+ size: node.Size,
+ isReadable: node.IsReadable,
+ isEditable: node.IsEditable,
+ isWritable: node.IsWritable,
+ IsDownloadable: node.IsDownloadable,
+ contentType: node.ContentType,
+ modTime: node.ChDate,
+ }
+ fs.Debugf(
+ f,
+ "NewObject: resolved remote=%q id=%q size=%d contentType=%q",
+ remote,
+ object.id,
+ object.size,
+ object.contentType,
+ )
+
+ return object, nil
+}
+
+func (f *Fs) List(
+ ctx context.Context,
+ dir string,
+) (entries fs.DirEntries, err error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ fs.Debugf(f, "List: start dir=%q rootPath=%q", dir, f.relativeRootPath)
+
+ entries, err = f.ft.ListEntries(f, dir)
+ if err != nil {
+ fs.Debugf(f, "List: failed dir=%q err=%v", dir, err)
+ return nil, err
+ }
+
+ fs.Debugf(f, "List: done dir=%q entries=%d", dir, len(entries))
+ return entries, nil
+}
+
+func fileRefIDFromLocation(location string) (string, error) {
+ if location == "" {
+ return "", errors.New("upload location is empty")
+ }
+
+ u, err := url.Parse(location)
+ if err != nil {
+ return "", fmt.Errorf("invalid upload location %q: %w", location, err)
+ }
+
+ pathParts := splitPath(u.Path)
+ if len(pathParts) == 0 {
+ return "", fmt.Errorf("upload location path is empty: %q", location)
+ }
+
+ last := pathParts[len(pathParts)-1]
+ if last == "content" {
+ if len(pathParts) < 2 {
+ return "", fmt.Errorf("upload location missing file-ref id: %q", location)
+ }
+ last = pathParts[len(pathParts)-2]
+ }
+
+ id := cleanPath(last)
+ if id == "" {
+ return "", fmt.Errorf("invalid upload location path %q", u.Path)
+ }
+
+ return id, nil
+}
+
+// cleanPath returns the shortest path name equivalent to path
+func cleanPath(p string) string {
+ cleanedPath := path.Clean(p)
+ if cleanedPath == "." || cleanedPath == "/" {
+ return ""
+ }
+
+ return strings.TrimPrefix(cleanedPath, "/")
+}
+
+func joinPath(parts ...string) string {
+ return cleanPath(path.Join(parts...))
+}
+
+// dirPath returns all but the last element of path, typically the path's directory.
+// If the path is empty, Dir returns "".
+func dirPath(p string) string {
+ return cleanPath(path.Dir(p))
+}
+
+func splitPath(p string) []string {
+ p = cleanPath(p)
+ if p == "" {
+ return []string{}
+ }
+
+ return strings.Split(p, "/")
+}
+
+// basePath returns the last element of path.
+// Trailing slashes are removed before extracting the last element.
+// If the path is empty, Base returns "".
+// If the path consists entirely of slashes, Base returns "".
+func basePath(p string) string {
+ return cleanPath(path.Base(p))
+}
diff --git a/backend/studip/object.go b/backend/studip/object.go
new file mode 100644
index 0000000..a6b8e94
--- /dev/null
+++ b/backend/studip/object.go
@@ -0,0 +1,388 @@
+package studip
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/hash"
+)
+
+type Object struct {
+ fs *Fs
+ remote string
+ id string
+ size int64
+ isReadable bool
+ isEditable bool
+ isWritable bool
+ IsDownloadable bool
+ contentType string
+ modTime time.Time
+}
+
+func (o *Object) fieldsForLog() string {
+ if o == nil {
+ return "<nil>"
+ }
+
+ return fmt.Sprintf(
+ "remote=%q id=%q size=%d isReadable=%t isEditable=%t isWritable=%t isDownloadable=%t contentType=%q modTime=%q",
+ o.remote,
+ o.id,
+ o.size,
+ o.isReadable,
+ o.isEditable,
+ o.isWritable,
+ o.IsDownloadable,
+ o.contentType,
+ o.modTime.Format(time.RFC3339Nano),
+ )
+}
+
+func (o *Object) Fs() fs.Info {
+ return o.fs
+}
+
+func (o *Object) String() string {
+ if o == nil {
+ return "<nil>"
+ }
+ return o.remote
+}
+
+func (o *Object) Remote() string {
+ return o.remote
+}
+
+func (o *Object) Hash(ctx context.Context, r hash.Type) (string, error) {
+ return "", hash.ErrUnsupported
+}
+
+func (o *Object) Size() int64 {
+ return o.size
+}
+
+// ModTime returns the modification time of the remote http file
+func (o *Object) ModTime(ctx context.Context) time.Time {
+ return o.modTime
+}
+
+func (o *Object) SetModTime(ctx context.Context, t time.Time) error {
+ o.modTime = t
+
+ return nil
+}
+func (o *Object) MimeType(ctx context.Context) string { return "" }
+
+func (o *Object) Open(
+ ctx context.Context,
+ options ...fs.OpenOption,
+) (io.ReadCloser, error) {
+ if o == nil {
+ return nil, fmt.Errorf("object is nil")
+ }
+ if o.fs == nil {
+ return nil, fmt.Errorf("object fs is nil")
+ }
+
+ fs.Debugf(o.fs, "Object.Open: start fields={%s} options=%d", o.fieldsForLog(), len(options))
+ if ctx.Err() != nil {
+ fs.Debugf(o.fs, "Object.Open: context canceled remote=%q err=%v", o.remote, ctx.Err())
+ return nil, ctx.Err()
+ }
+
+ if !o.isReadable && !o.IsDownloadable {
+ fs.Debugf(o.fs, "Object.Open: permission denied fields={%s}", o.fieldsForLog())
+ return nil, fs.ErrorPermissionDenied
+ }
+
+ rc, err := o.fs.studIPOpenFileContent(ctx, o.id, options...)
+ if err != nil {
+ fs.Debugf(o.fs, "Object.Open: failed fields={%s} err=%v", o.fieldsForLog(), err)
+ return nil, err
+ }
+ fs.Debugf(o.fs, "Object.Open: success fields={%s}", o.fieldsForLog())
+ return rc, nil
+}
+
+func (o *Object) Storable() bool {
+ return true
+}
+
+func (o *Object) Update(
+ ctx context.Context,
+ in io.Reader,
+ src fs.ObjectInfo,
+ options ...fs.OpenOption,
+) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ Assert(
+ o != nil,
+ fmt.Sprintf(
+ "o must be not nil; o=%q",
+ o,
+ ),
+ )
+
+ Assert(
+ o.fs != nil,
+ fmt.Sprintf(
+ "o.fs must be not nil; o.fs=%q",
+ o.fs,
+ ),
+ )
+
+ Assert(
+ in != nil,
+ fmt.Sprintf(
+ "in must be not nil; in=%q",
+ in,
+ ),
+ )
+
+ Assert(
+ src != nil,
+ fmt.Sprintf(
+ "src must be not nil; src=%q",
+ src,
+ ),
+ )
+
+ srcRemote := src.Remote()
+ fs.Debugf(o.fs, "Object.Update: start srcRemote=%q fields={%s}", srcRemote, o.fieldsForLog())
+
+ if !o.isEditable || !o.isWritable {
+ fs.Debugf(o.fs, "Object.Update: permission denied srcRemote=%q fields={%s}", srcRemote, o.fieldsForLog())
+ return fs.ErrorPermissionDenied
+ }
+
+ if o.id == "" {
+ fs.Debugf(o.fs, "Object.Update: missing file-ref id for srcRemote=%q", srcRemote)
+ return fmt.Errorf("cannot update %q: object id is empty", srcRemote)
+ }
+
+ // This weird branch fixed a case where sometimes a file update get's truncated to it's size instead of updating the size
+ sizeChanged := src.Size() >= 0 && o.size >= 0 && src.Size() != o.size
+ var location string
+ var err error
+ if sizeChanged {
+ fs.Debugf(
+ o.fs,
+ "Object.Update: size changed old=%d new=%d remote=%q; recreating file",
+ o.size,
+ src.Size(),
+ o.remote,
+ )
+ location, err = o.recreateForSizeChangingUpdate(ctx, in, src)
+ } else {
+ location, err = o.fs.studIPUpdateFileContent(ctx, o.id, in, basePath(o.remote), src.Size())
+ }
+ if err != nil {
+ fs.Debugf(o.fs, "Object.Update: upload phase failed srcRemote=%q fields={%s} err=%v", srcRemote, o.fieldsForLog(), err)
+ return err
+ }
+
+ o.id, err = fileRefIDFromLocation(location)
+ if err != nil {
+ return err
+ }
+
+ o.size = src.Size()
+ o.modTime = src.ModTime(ctx)
+ o.contentType = fs.MimeType(ctx, src)
+
+ if o.fs.ft.relativeRoot != nil {
+ if node := o.fs.ft.relativeRoot.GetNodeAtPath(o.remote); node != nil && !node.IsDir {
+ node.ID = o.id
+ node.Size = o.size
+ node.ChDate = o.modTime
+ node.ContentType = o.contentType
+ }
+ }
+
+ err = o.SetTermsOfUse(ctx, o.fs.opt.License)
+ if err != nil {
+ return err
+ }
+
+ fs.Debugf(o.fs, "Object.Update: success srcRemote=%q location=%q fields={%s}", srcRemote, location, o.fieldsForLog())
+ return nil
+}
+
+func (o *Object) recreateForSizeChangingUpdate(
+ ctx context.Context,
+ in io.Reader,
+ src fs.ObjectInfo,
+) (string, error) {
+ parentNode, err := o.fs.CreateParentDirectories(ctx, dirPath(o.remote))
+ if err != nil {
+ return "", err
+ }
+ if parentNode == nil || !parentNode.IsDir || parentNode.ID == "" {
+ return "", fmt.Errorf("failed to resolve parent directory for %q", o.remote)
+ }
+
+ if err = o.fs.studIPDeleteFile(ctx, o.id); err != nil {
+ return "", err
+ }
+
+ location, err := o.fs.studIPCreateFileContent(
+ ctx,
+ parentNode.ID,
+ in,
+ basePath(o.remote),
+ src.Size(),
+ )
+ if err != nil {
+ return location, err
+ }
+
+ newID, err := fileRefIDFromLocation(location)
+ if err != nil {
+ return location, err
+ }
+ o.id = newID
+ o.size = src.Size()
+ o.modTime = src.ModTime(ctx)
+ o.contentType = fs.MimeType(ctx, src)
+
+ if o.fs.ft.relativeRoot != nil {
+ if node := o.fs.ft.relativeRoot.GetNodeAtPath(o.remote); node != nil && !node.IsDir {
+ node.ID = o.id
+ node.Size = o.size
+ node.ChDate = o.modTime
+ node.ContentType = o.contentType
+ }
+ }
+
+ err = o.SetTermsOfUse(ctx, o.fs.opt.License)
+ if err != nil {
+ return location, err
+ }
+
+ return location, nil
+}
+
+func (o *Object) SetTermsOfUse(
+ ctx context.Context,
+ licenseID string,
+) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ Assert(
+ o != nil,
+ fmt.Sprintf(
+ "o must be not nil; o=%q",
+ o,
+ ),
+ )
+
+ Assert(
+ o.fs != nil,
+ fmt.Sprintf(
+ "o.fs must be not nil; o.fs=%q",
+ o.fs,
+ ),
+ )
+
+ Assert(
+ o.id != "",
+ fmt.Sprintf(
+ "o.id must be not empty; o.id=%q",
+ o.id,
+ ),
+ )
+
+ fs.Debugf(o.fs, "Object.SetTermsOfUse: start license=%q fields={%s}", licenseID, o.fieldsForLog())
+
+ if licenseID == "" {
+ return fmt.Errorf("licenseID is empty")
+ }
+
+ if !o.isEditable {
+ fs.Debugf(o.fs, "Object.SetTermsOfUse: permission denied fields={%s}", o.fieldsForLog())
+ return fs.ErrorPermissionDenied
+ }
+
+ err := o.fs.studIPSetTermsOfUse(ctx, o.id, licenseID)
+ if err != nil {
+ fs.Debugf(o.fs, "Object.SetTermsOfUse: failed license=%q fields={%s} err=%v", licenseID, o.fieldsForLog(), err)
+ return err
+ }
+
+ fs.Debugf(o.fs, "Object.SetTermsOfUse: success license=%q fields={%s}", licenseID, o.fieldsForLog())
+
+ return nil
+}
+
+func (o *Object) Remove(ctx context.Context) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ Assert(
+ o != nil,
+ fmt.Sprintf(
+ "o must be not nil; o=%q",
+ o,
+ ),
+ )
+
+ Assert(
+ o.fs != nil,
+ fmt.Sprintf(
+ "o.fs must be not nil; o.fs=%q",
+ o.fs,
+ ),
+ )
+
+ Assert(
+ o.id != "",
+ fmt.Sprintf(
+ "o.id must be not empty; o.id=%q",
+ o.id,
+ ),
+ )
+
+ fs.Debugf(o.fs, "Object.Remove: start fields={%s}", o.fieldsForLog())
+
+ if !o.isEditable && !o.isWritable {
+ fs.Debugf(o.fs, "Object.Remove: permission denied fields={%s}", o.fieldsForLog())
+ return fs.ErrorPermissionDenied
+ }
+
+ err := o.fs.studIPDeleteFile(ctx, o.id)
+ if err != nil {
+ fs.Debugf(o.fs, "Object.Remove: failed fields={%s} err=%v", o.fieldsForLog(), err)
+ return err
+ }
+
+ parent := o.fs.ft.relativeRoot
+ if parent != nil {
+ parentDir := dirPath(o.remote)
+ parent = parent.GetNodeAtPath(parentDir)
+ }
+ if parent != nil {
+ filename := basePath(o.remote)
+ for i, child := range parent.Children {
+ if child != nil && !child.IsDir && strings.EqualFold(child.Name, filename) {
+ parent.Children = slices.Delete(parent.Children, i, i+1)
+ break
+ }
+ }
+ }
+
+ fs.Debugf(o.fs, "Object.Remove: success fields={%s}", o.fieldsForLog())
+
+ return nil
+}
diff --git a/backend/studip/requests.go b/backend/studip/requests.go
new file mode 100644
index 0000000..afb5c04
--- /dev/null
+++ b/backend/studip/requests.go
@@ -0,0 +1,577 @@
+package studip
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/lib/rest"
+)
+
+// If the Directory already exists, studip will create a Directory with a suffix
+// TODO: Return the directoryID from the Location so we can check if a duplicate directory was created so we can delete it in that case
+func (f *Fs) studIPMkDir(
+ ctx context.Context,
+ parentDirectoryID string,
+ filename string,
+) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ Assert(
+ parentDirectoryID != "",
+ fmt.Sprintf(
+ "parentDirectoryID must be not empty; parentDirectoryID=%q",
+ parentDirectoryID,
+ ),
+ )
+
+ Assert(
+ filename != "",
+ fmt.Sprintf(
+ "filename must be not empty; filename=%q",
+ filename,
+ ),
+ )
+
+ URL := fmt.Sprintf("courses/%s/folders", f.opt.CourseID)
+
+ fs.Debugf(
+ f,
+ "studIPMkDir: request parentID=%q name=%q path=%q",
+ parentDirectoryID,
+ filename,
+ URL,
+ )
+
+ payload := struct {
+ Data struct {
+ Type string `json:"type"`
+ Attributes struct {
+ Name string `json:"name"`
+ } `json:"attributes"`
+ Relationships struct {
+ Parent struct {
+ Data struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ } `json:"data"`
+ } `json:"parent"`
+ } `json:"relationships"`
+ } `json:"data"`
+ }{}
+
+ payload.Data.Type = "folders"
+ payload.Data.Attributes.Name = filename
+ payload.Data.Relationships.Parent.Data.Type = "folders"
+ payload.Data.Relationships.Parent.Data.ID = parentDirectoryID
+
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+
+ res, err := f.client.Call(ctx, &rest.Opts{
+ Method: "POST",
+ Path: URL,
+ ContentType: "application/vnd.api+json",
+ Body: bytes.NewReader(body),
+ })
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+
+ fs.Debugf(f, "studIPMkDir: created name=%q under parentID=%q", filename, parentDirectoryID)
+
+ return nil
+}
+
+func (f *Fs) studIPGetFoldersOfFolder(
+ ctx context.Context,
+ folderID string,
+) (*StudIPFolders, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ URL := fmt.Sprintf("folders/%s/folders", folderID)
+
+ responseJSON := &StudIPFolders{}
+ res, err := f.client.CallJSON(
+ ctx,
+ &rest.Opts{Method: "GET", Path: URL},
+ nil,
+ responseJSON,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ return responseJSON, nil
+}
+
+func (f *Fs) studIPGetFilesOfFolder(
+ ctx context.Context,
+ folderID string,
+) (*StudIPFiles, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ URL := fmt.Sprintf("folders/%s/file-refs", folderID)
+
+ responseJSON := &StudIPFiles{}
+ res, err := f.client.CallJSON(
+ ctx,
+ &rest.Opts{Method: "GET", Path: URL},
+ nil,
+ responseJSON,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ return responseJSON, nil
+}
+
+func (f *Fs) studIPGetCourseFolders(ctx context.Context) (*StudIPFolders, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ URL := fmt.Sprintf("courses/%s/folders", f.opt.CourseID)
+
+ responseJSON := &StudIPFolders{}
+ res, err := f.client.CallJSON(
+ ctx,
+ &rest.Opts{Method: "GET", Path: URL},
+ nil,
+ responseJSON,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ return responseJSON, nil
+}
+
+func (f *Fs) studIPDeleteFolder(ctx context.Context, folderID string) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ URL := fmt.Sprintf("folders/%s", folderID)
+
+ res, err := f.client.Call(ctx, &rest.Opts{
+ Method: "DELETE",
+ Path: URL,
+ })
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+
+ return nil
+}
+
+func (f *Fs) studIPGetCourse(ctx context.Context) (*StudIPCourses, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ URL := fmt.Sprintf("courses/%s", f.opt.CourseID)
+
+ responseJSON := new(StudIPCourses)
+ res, err := f.client.CallJSON(
+ ctx,
+ &rest.Opts{Method: "GET", Path: URL},
+ nil,
+ responseJSON,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ return responseJSON, nil
+}
+
+func (f *Fs) studIPOpenFileContent(
+ ctx context.Context,
+ fileRefID string,
+ options ...fs.OpenOption,
+) (io.ReadCloser, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ if fileRefID == "" {
+ return nil, errors.New("fileRefID is empty")
+ }
+
+ URL := fmt.Sprintf("file-refs/%s/content", fileRefID)
+
+ opts := rest.Opts{Method: "GET", Path: URL}
+ opts.Options = options
+
+ res, err := f.client.Call(ctx, &opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return res.Body, nil
+}
+
+func (f *Fs) studIPCreateFileContent(
+ ctx context.Context,
+ parentFolderID string,
+ in io.Reader,
+ filename string,
+ size int64,
+) (location string, err error) {
+ if ctx.Err() != nil {
+ return "", ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ if parentFolderID == "" {
+ return "", errors.New("parent folder id is empty")
+ }
+ if in == nil {
+ return "", errors.New("input reader is nil")
+ }
+ if filename == "" {
+ return "", fmt.Errorf("invalid filename %q", filename)
+ }
+ if size < -1 {
+ return "", fmt.Errorf("invalid size %d", size)
+ }
+
+ URL := fmt.Sprintf("folders/%s/file-refs", parentFolderID)
+ fs.Debugf(
+ f,
+ "studIPCreateFileContent: start parentFolderID=%q filename=%q path=%q",
+ parentFolderID,
+ filename,
+ URL,
+ )
+
+ return f.studIPUploadFileContentToPath(ctx, URL, in, filename, size)
+}
+
+func (f *Fs) studIPUpdateFileContent(
+ ctx context.Context,
+ fileRefID string,
+ in io.Reader,
+ filename string,
+ size int64,
+) (location string, err error) {
+ if ctx.Err() != nil {
+ return "", ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ if fileRefID == "" {
+ return "", errors.New("fileRefID is empty")
+ }
+ if in == nil {
+ return "", errors.New("input reader is nil")
+ }
+ if filename == "" {
+ return "", fmt.Errorf("invalid filename %q", filename)
+ }
+
+ URL := fmt.Sprintf("file-refs/%s/content", fileRefID)
+ fs.Debugf(
+ f,
+ "studIPUpdateFileContent: start fileRefID=%q filename=%q path=%q",
+ fileRefID,
+ filename,
+ URL,
+ )
+
+ return f.studIPUploadFileContentToPath(ctx, URL, in, filename, size)
+}
+
+func (f *Fs) studIPUploadFileContentToPath(
+ ctx context.Context,
+ URL string,
+ in io.Reader,
+ filename string,
+ size int64,
+) (location string, err error) {
+ if ctx.Err() != nil {
+ return "", ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ if URL == "" {
+ return "", errors.New("URL is empty")
+ }
+ if in == nil {
+ return "", errors.New("input reader is nil")
+ }
+ if filename == "" {
+ return "", fmt.Errorf("invalid filename %q", filename)
+ }
+
+ // Read first 512 bytes for content type detection.
+ buffer := make([]byte, 512)
+ n, err := io.ReadFull(in, buffer)
+ if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
+ return "", err
+ }
+
+ detectedType := http.DetectContentType(buffer[:n])
+ fs.Debugf(
+ f,
+ "studIPUploadFileContentToPath: detected content-type=%q sampled-bytes=%d",
+ detectedType,
+ n,
+ )
+
+ // Reconstruct reader so we don't lose the first bytes.
+ fullReader := io.MultiReader(bytes.NewReader(buffer[:n]), in)
+
+ multipartBody, multipartType, overhead, err := rest.MultipartUpload(
+ ctx,
+ fullReader,
+ url.Values{},
+ "file",
+ filename,
+ detectedType,
+ )
+ if err != nil {
+ return "", err
+ }
+
+ defer multipartBody.Close()
+
+ opts := &rest.Opts{
+ Method: "POST",
+ Path: URL,
+ ContentType: multipartType,
+ Body: multipartBody,
+ }
+
+ if size >= 0 {
+ contentLength := size + overhead
+ opts.ContentLength = &contentLength
+ fs.Debugf(
+ f,
+ "studIPUploadFileContentToPath: using content-length=%d (file=%d overhead=%d)",
+ contentLength,
+ size,
+ overhead,
+ )
+ }
+ res, err := f.client.Call(ctx, opts)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+
+ if res.Request != nil {
+ requestURL := ""
+ if res.Request.URL != nil {
+ requestURL = res.Request.URL.String()
+ }
+ fs.Debugf(
+ f,
+ "StudIP upload request: method=%s url=%s content-type=%q content-length=%d transfer-encoding=%v\n",
+ res.Request.Method,
+ requestURL,
+ res.Request.Header.Get("Content-Type"),
+ res.Request.ContentLength,
+ res.Request.TransferEncoding,
+ )
+ }
+ fs.Debugf(
+ f,
+ "StudIP upload response: status=%s location=%q\n",
+ res.Status,
+ res.Header.Get("Location"),
+ )
+
+ location = res.Header.Get("Location")
+ if location == "" {
+ return "", errors.New("no Location header returned")
+ }
+ fs.Debugf(f, "studIPUploadFileContentToPath: success path=%q location=%q", URL, location)
+
+ return location, nil
+}
+
+func (f *Fs) studIPSetTermsOfUse(
+ ctx context.Context,
+ fileRefID string,
+ licenseID string,
+) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ if fileRefID == "" {
+ return errors.New("fileRefID is empty")
+ }
+
+ if fileRefID == "" {
+ return errors.New("licenseID is empty")
+ }
+
+ URL := fmt.Sprintf("file-refs/%s/relationships/terms-of-use", fileRefID)
+
+ payload := struct {
+ Data struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ } `json:"data"`
+ }{}
+ payload.Data.Type = "terms-of-use"
+ payload.Data.ID = licenseID
+
+ b, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+
+ res, err := f.client.Call(ctx, &rest.Opts{
+ Method: "PATCH",
+ Path: URL,
+ ContentType: "application/vnd.api+json",
+ Body: bytes.NewReader(b),
+ })
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+
+ return nil
+}
+
+func (f *Fs) studIPDeleteFile(ctx context.Context, fileRefID string) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ Assert(
+ f != nil,
+ fmt.Sprintf(
+ "f must be not nil; f=%q",
+ f,
+ ),
+ )
+
+ if fileRefID == "" {
+ return errors.New("fileRefID is empty")
+ }
+
+ URL := fmt.Sprintf("file-refs/%s", fileRefID)
+
+ res, err := f.client.Call(ctx, &rest.Opts{
+ Method: "DELETE",
+ Path: URL,
+ })
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+
+ return nil
+}
diff --git a/backend/studip/responses.go b/backend/studip/responses.go
new file mode 100644
index 0000000..d328ec9
--- /dev/null
+++ b/backend/studip/responses.go
@@ -0,0 +1,86 @@
+package studip
+
+import "time"
+
+type StudIPFolders struct {
+ Meta struct {
+ Page struct {
+ Offset int `json:"offset"`
+ Limit int `json:"limit"`
+ Total int `json:"total"`
+ } `json:"page"`
+ } `json:"meta"`
+ Data []StudIPFoldersData `json:"data"`
+}
+
+type StudIPFoldersData struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ Attributes struct {
+ FolderType string `json:"folder-type"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Mkdate time.Time `json:"mkdate"`
+ Chdate time.Time `json:"chdate"`
+ IsVisible bool `json:"is-visible"`
+ IsReadable bool `json:"is-readable"`
+ IsWritable bool `json:"is-writable"`
+ IsEditable bool `json:"is-editable"`
+ IsEmpty bool `json:"is-empty"`
+ IsSubfolderAllowed bool `json:"is-subfolder-allowed"`
+ } `json:"attributes"`
+ Relationships struct {
+ Parent struct {
+ Data struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ } `json:"data"`
+ } `json:"parent"`
+ } `json:"relationships"`
+}
+
+type StudIPFiles struct {
+ Meta struct {
+ Page struct {
+ Offset int `json:"offset"`
+ Limit int `json:"limit"`
+ Total int `json:"total"`
+ } `json:"page"`
+ } `json:"meta"`
+ Links struct {
+ First string `json:"first"`
+ Last string `json:"last"`
+ } `json:"links"`
+ Data []struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ Attributes struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Mkdate time.Time `json:"mkdate"`
+ Chdate time.Time `json:"chdate"`
+ Downloads int `json:"downloads"`
+ Filesize int64 `json:"filesize"`
+ MimeType string `json:"mime-type"`
+ IsReadable bool `json:"is-readable"`
+ IsDownloadable bool `json:"is-downloadable"`
+ IsEditable bool `json:"is-editable"`
+ IsWritable bool `json:"is-writable"`
+ } `json:"attributes"`
+ } `json:"data"`
+}
+
+type StudIPCourses struct {
+ Data struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ Attributes struct {
+ CourseNumber string `json:"course-number"`
+ Title string `json:"title"`
+ CourseType int `json:"course-type"`
+ CourseTypeText string `json:"course-type-text"`
+ Description string `json:"description"`
+ Dates string `json:"dates"`
+ } `json:"attributes"`
+ } `json:"data"`
+}
diff --git a/backend/studip/stack.go b/backend/studip/stack.go
new file mode 100644
index 0000000..2fefb66
--- /dev/null
+++ b/backend/studip/stack.go
@@ -0,0 +1,41 @@
+package studip
+
+import "container/list"
+
+type Stack[T any] struct {
+ list *list.List
+}
+
+func NewStack[T any]() *Stack[T] {
+ return &Stack[T]{list: list.New()}
+}
+
+func (s *Stack[T]) Push(v T) {
+ s.list.PushBack(v)
+}
+
+func (s *Stack[T]) Pop() (T, bool) {
+ if s.list.Len() == 0 {
+ var zero T
+ return zero, false
+ }
+ elem := s.list.Back()
+
+ return s.list.Remove(elem).(T), true
+}
+
+func (s *Stack[T]) Peek() (T, bool) {
+ if s.list.Len() == 0 {
+ var zero T
+ return zero, false
+ }
+ return s.list.Back().Value.(T), true
+}
+
+func (s *Stack[T]) Len() int {
+ return s.list.Len()
+}
+
+func (s *Stack[T]) IsEmpty() bool {
+ return s.list.Len() == 0
+}
diff --git a/backend/studip/studip.go b/backend/studip/studip.go
index 46021e2..409f54e 100644
--- a/backend/studip/studip.go
+++ b/backend/studip/studip.go
@@ -1,689 +1,126 @@
package studip
import (
- "context"
- "encoding/json"
- "errors"
"fmt"
- "io"
- "net/http"
- "net/url"
- "os"
- "path"
"path/filepath"
- "slices"
- "sort"
- "strings"
- "time"
+ "runtime"
"github.com/rclone/rclone/fs"
- "github.com/rclone/rclone/fs/config/configmap"
- "github.com/rclone/rclone/fs/config/configstruct"
- "github.com/rclone/rclone/fs/config/obscure"
- "github.com/rclone/rclone/fs/fshttp"
- "github.com/rclone/rclone/fs/hash"
- "github.com/rclone/rclone/lib/rest"
+ "github.com/rclone/rclone/fs/config"
+ "github.com/rclone/rclone/lib/encoder"
)
func init() {
fs.Register(&fs.RegInfo{
Name: "Stud.IP",
- Description: "Stud.IP – read only",
+ Prefix: "studip",
+ Description: "Stud.IP",
NewFs: NewFs,
Options: []fs.Option{{
Name: "base_url",
- Help: "Base URL of Stud.IP installation",
+ Help: "Base URL of the Stud.IP JSON API v1 endpoint",
Default: "https://elearning.uni-bremen.de/jsonapi.php/v1/",
Required: true,
}, {
Name: "username",
- Help: "Stud.IP login name",
+ Help: "Stud.IP username used for login",
Required: true,
}, {
Name: "password",
- Help: "Stud.IP password",
+ Help: "Stud.IP password used for login",
IsPassword: true,
Required: true,
}, {
Name: "course_id",
- Help: "Course ID",
+ Help: "Stud.IP course ID (e.g. 59e88658b39093836455413bd1f24f29)",
Required: true,
+ }, {
+ Name: "license",
+ Help: "License ID applied to uploaded files",
+ Required: true,
+ Default: "UNDEF_LICENSE",
+ Examples: fs.OptionExamples{
+ fs.OptionExample{
+ Value: "FREE_LICENSE",
+ Help: "Works that have been published under a free license, i.e. the distribution and usually also modification of which is permitted without license costs, may be made available for teaching without restrictions. \n\nTypical examples are:\n- Open Access publications \n- Open Educational Resources (OER) \n- Works under Creative Commons licenses (e.g. Wikipedia content) \n\nAttention: Make sure on a case-by-case basis what restrictions on distribution and modification the respective license may contain.",
+ },
+ fs.OptionExample{
+ Value: "SELFMADE_NONPUB",
+ Help: "Self-authored, unpublished work",
+ },
+ fs.OptionExample{
+ Value: "NON_TEXTUAL",
+ Help: "Copyright-protected and published works",
+ },
+ fs.OptionExample{
+ Value: "TEXT_NO_LICENSE",
+ Help: "Published texts without an acquired license or separate permission",
+ },
+ fs.OptionExample{
+ Value: "WITH_LICENSE",
+ Help: "Permission of use or license exists",
+ },
+ fs.OptionExample{
+ Value: "UNDEF_LICENSE",
+ Help: "Unclear License",
+ },
+ },
+ }, {
+ Name: config.ConfigEncoding,
+ Help: config.ConfigEncodingHelp,
+ Advanced: true,
+ Default: (encoder.Base |
+ encoder.EncodeLeftSpace |
+ encoder.EncodeRightSpace |
+ encoder.EncodeCrLf |
+ encoder.EncodeLeftCrLfHtVt |
+ encoder.EncodeRightCrLfHtVt |
+ encoder.EncodeInvalidUtf8),
},
},
})
}
type Options struct {
- BaseURL string `config:"base_url"`
- Username string `config:"username"`
- Password string `config:"password"`
- CourseID string `config:"course_id"`
-}
-
-type Fs struct {
- name string
- opt *Options
- client *rest.Client
- root string
-
- rootNode *Node
-}
-
-type Object struct {
- fs *Fs
- remote string
- id string
- size int64
- isDir bool
- contentType string
- modTime time.Time
-}
-
-func NewFs(
- ctx context.Context,
- name,
- root string,
- m configmap.Mapper,
-) (fs.Fs, error) {
- if ctx.Err() != nil {
- return nil, ctx.Err()
- }
-
- fs.Debugf(name, "initializing studip backend for root %q", root)
- opt := new(Options)
- if err := configstruct.Set(m, opt); err != nil {
- return nil, err
- }
- if opt.CourseID == "" {
- return nil, errors.New("course_id is required")
- }
-
- base, err := url.Parse(opt.BaseURL)
- if err != nil {
- return nil, fmt.Errorf("invalid base_url: %w", err)
- }
-
- var httpClient *rest.Client
- {
- c := fshttp.NewClient(context.Background())
- httpClient = rest.NewClient(c)
- }
-
- httpClient.SetRoot(base.String())
- httpClient.SetHeader("Accept", "application/vnd.api+json")
- httpClient.SetUserPass(opt.Username, obscure.MustReveal(opt.Password))
- httpClient.SetErrorHandler(func(resp *http.Response) error {
- fmt.Println("Status: " + resp.Status)
- fmt.Println("URL: " + resp.Request.URL.String())
- return errors.New("")
- })
-
- f := &Fs{
- name: name,
- opt: opt,
- client: httpClient,
- root: root,
- }
-
- if err := f.TestConnection(ctx); err != nil {
- return nil, err
- }
-
- rootID, err := f.RetrieveRootFolderID(ctx)
- if err != nil {
- return nil, err
- }
-
- rootNode, err := f.GetCourseFileTree(ctx, rootID)
- if err != nil {
- return nil, err
- }
-
- f.rootNode = rootNode
-
- if root != "" {
- pathSplit := splitPath(filepath.Dir(root))
- f.rootNode = GetNodeAtPath(f.rootNode, pathSplit)
- }
-
- return f, nil
-}
-
-type StudIPFolders struct {
- Meta struct {
- Page struct {
- Offset int `json:"offset"`
- Limit int `json:"limit"`
- Total int `json:"total"`
- } `json:"page"`
- } `json:"meta"`
- Data []StudIPFoldersData `json:"data"`
-}
-
-type StudIPFoldersData struct {
- Type string `json:"type"`
- ID string `json:"id"`
- Attributes struct {
- FolderType string `json:"folder-type"`
- Name string `json:"name"`
- Description string `json:"description"`
- Mkdate time.Time `json:"mkdate"`
- Chdate time.Time `json:"chdate"`
- IsVisible bool `json:"is-visible"`
- IsReadable bool `json:"is-readable"`
- IsWritable bool `json:"is-writable"`
- IsEditable bool `json:"is-editable"`
- IsEmpty bool `json:"is-empty"`
- IsSubfolderAllowed bool `json:"is-subfolder-allowed"`
- } `json:"attributes"`
-}
-
-type StudIPFiles struct {
- Meta struct {
- Page struct {
- Offset int `json:"offset"`
- Limit int `json:"limit"`
- Total int `json:"total"`
- } `json:"page"`
- } `json:"meta"`
- Links struct {
- First string `json:"first"`
- Last string `json:"last"`
- } `json:"links"`
- Data []struct {
- Type string `json:"type"`
- ID string `json:"id"`
- Attributes struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Mkdate time.Time `json:"mkdate"`
- Chdate time.Time `json:"chdate"`
- Downloads int `json:"downloads"`
- Filesize int64 `json:"filesize"`
- MimeType string `json:"mime-type"`
- IsReadable bool `json:"is-readable"`
- IsDownloadable bool `json:"is-downloadable"`
- IsEditable bool `json:"is-editable"`
- IsWritable bool `json:"is-writable"`
- } `json:"attributes"`
- } `json:"data"`
-}
-
-type StudIPCourses struct {
- Data struct {
- Type string `json:"type"`
- ID string `json:"id"`
- Attributes struct {
- CourseNumber string `json:"course-number"`
- Title string `json:"title"`
- CourseType int `json:"course-type"`
- CourseTypeText string `json:"course-type-text"`
- Description string `json:"description"`
- Dates string `json:"dates"`
- } `json:"attributes"`
- } `json:"data"`
-}
-
-func (f *Fs) TestConnection(
- ctx context.Context,
-) error {
- if ctx.Err() != nil {
- return ctx.Err()
- }
-
- URL := fmt.Sprintf("courses/%s", f.opt.CourseID)
-
- responseJSON := new(StudIPCourses)
- res, err := f.client.Call(
- ctx,
- &rest.Opts{Method: "GET", Path: URL},
- )
- if err != nil {
- return err
- }
-
- defer res.Body.Close()
- decoder := json.NewDecoder(res.Body)
- err = decoder.Decode(responseJSON)
- if err != nil {
- return err
- }
-
- if responseJSON.Data.ID != f.opt.CourseID {
- return fmt.Errorf("received courseID doesn't match"+
- " configured courseID, received: %s, want: %s",
- responseJSON.Data.ID, f.opt.CourseID)
- }
-
- return nil
-}
-
-func (f *Fs) Name() string { return f.name }
-
-func (f *Fs) Root() string { return f.root }
-func (f *Fs) String() string { return f.opt.BaseURL }
-func (f *Fs) Precision() time.Duration { return time.Second }
-
-func (f *Fs) Hashes() hash.Set { return hash.Set(hash.None) }
-func (f *Fs) Features() *fs.Features {
- return (&fs.Features{CanHaveEmptyDirectories: true}).
- Fill(context.Background(), f)
-}
-
-func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
- return nil, fs.ErrorNotImplemented
-}
-
-// List the objects and directories in dir into entries. The
-// entries can be returned in any order but should be for a
-// complete directory.
-//
-// dir should be "" to list the root, and should not have
-// trailing slashes.
-//
-// This should return ErrDirNotFound if the directory isn't
-// found.
-func (f *Fs) List(
- ctx context.Context,
- dir string,
-) (entries fs.DirEntries, err error) {
- if ctx.Err() != nil {
- return nil, ctx.Err()
- }
-
- pathSplit := strings.Split(dir, string(os.PathSeparator))
-
- node := GetNodeAtPath(f.rootNode, pathSplit)
- if !node.IsDir || node == nil {
- return nil, fs.ErrorDirNotFound
- }
-
- for _, entry := range node.Children {
- if entry.IsDir {
- directory := new(Directory)
- directory.fs = f
- directory.remote = filepath.Join(dir, entry.Name)
- directory.id = entry.Id
- directory.items = int64(len(entry.Children))
- directory.name = entry.Name
- directory.modTime = entry.ChDate
-
- entries = append(entries, directory)
-
- } else {
- object := new(Object)
- object.fs = f
- object.remote = filepath.Join(dir, entry.Name)
- object.id = entry.Id
- object.size = entry.Size
- object.contentType = entry.ContentType
- object.modTime = entry.ChDate
- object.isDir = entry.IsDir
-
- entries = append(entries, object)
- }
- }
-
- sort.Slice(entries, func(i, j int) bool {
- return entries.Less(i, j)
- })
-
- return entries, nil
-}
-
-type Node struct {
- Children []*Node
- Name string
- Id string
- IsDir bool
- ChDate time.Time
- Size int64
- ContentType string
-}
-
-func GetNodeAtPath(
- node *Node,
- pathSplit []string,
-) *Node {
- if len(pathSplit) == 0 {
- return node
- }
-
- if pathSplit[0] == "." {
- return node
- }
-
- if pathSplit[0] == "" {
- return node
- }
-
- for _, children := range node.Children {
- if children.Name == pathSplit[0] {
- return GetNodeAtPath(children, pathSplit[1:])
- }
- }
-
- return nil
-}
-
-func (f *Fs) GetCourseFileTree(
- ctx context.Context,
- rootFolderID string,
-) (*Node, error) {
- if ctx.Err() != nil {
- return nil, ctx.Err()
- }
-
- rootNode := new(Node)
- rootNode.IsDir = true
- rootNode.Id = rootFolderID
-
- err := f.FillFolderNode(ctx, rootNode)
- if err != nil {
- return nil, err
- }
-
- return rootNode, nil
-}
-
-func (f *Fs) FillFolderNode(
- ctx context.Context,
- folderNode *Node,
-) error {
- if ctx.Err() != nil {
- return ctx.Err()
- }
-
- if !folderNode.IsDir {
- return errors.New("node isn't a folder")
- }
-
- folders, err := f.RetrieveFoldersOfFolder(ctx, folderNode.Id)
- if err != nil {
- return err
- }
-
- folderNode.Children = slices.Grow(folderNode.Children, len(folders.Data))
-
- for _, folder := range folders.Data {
- childrenNode := new(Node)
- childrenNode.Id = folder.ID
- childrenNode.IsDir = true
- childrenNode.Name = folder.Attributes.Name
- childrenNode.ChDate = folder.Attributes.Chdate
- childrenNode.Size = -1
-
- folderNode.Children = append(folderNode.Children, childrenNode)
- }
-
- {
- errChan := make(chan error)
- length := len(folderNode.Children)
- {
- for _, childrenNode := range folderNode.Children {
- go func() {
- errChan <- f.FillFolderNode(ctx, childrenNode)
- }()
- }
- }
-
- for range length {
- err := <-errChan
- if err != nil {
- return err
- }
- }
- }
-
- files, err := f.RetrieveFilesOfFolder(ctx, folderNode.Id)
- if err != nil {
- return err
- }
-
- folderNode.Children = slices.Grow(folderNode.Children, len(files.Data))
-
- for _, file := range files.Data {
- if !file.Attributes.IsReadable || !file.Attributes.IsDownloadable {
- continue
- }
-
- childrenNode := new(Node)
- childrenNode.Id = file.ID
- childrenNode.IsDir = false
- childrenNode.Name = file.Attributes.Name
- childrenNode.ChDate = file.Attributes.Chdate
- childrenNode.Size = file.Attributes.Filesize
- childrenNode.ContentType = file.Attributes.MimeType
-
- folderNode.Children = append(folderNode.Children, childrenNode)
- }
-
- return nil
+ BaseURL string `config:"base_url"`
+ Username string `config:"username"`
+ Password string `config:"password"`
+ CourseID string `config:"course_id"`
+ License string `config:"license"`
+ Enc encoder.MultiEncoder `config:"encoding"`
}
-func (f *Fs) RetrieveFoldersOfFolder(
- ctx context.Context,
- folderID string,
-) (*StudIPFolders, error) {
- if ctx.Err() != nil {
- return nil, ctx.Err()
- }
-
- URL := fmt.Sprintf("folders/%s/folders", folderID)
-
- responseJSON := &StudIPFolders{}
- res, err := f.client.CallJSON(ctx,
- &rest.Opts{Method: "GET", Path: URL},
- nil,
- responseJSON,
- )
- if err != nil {
- return nil, err
- }
- defer res.Body.Close()
-
- return responseJSON, nil
-}
-
-func (f *Fs) RetrieveFilesOfFolder(
- ctx context.Context,
- folderID string,
-) (*StudIPFiles, error) {
- if ctx.Err() != nil {
- return nil, ctx.Err()
- }
-
- URL := fmt.Sprintf("folders/%s/file-refs", folderID)
-
- responseJSON := &StudIPFiles{}
- res, err := f.client.CallJSON(
- ctx,
- &rest.Opts{Method: "GET", Path: URL},
- nil,
- responseJSON,
- )
- if err != nil {
- return nil, err
- }
-
- defer res.Body.Close()
-
- return responseJSON, nil
-}
-
-func (f *Fs) RetrieveRootFolderID(
- ctx context.Context,
-) (id string, err error) {
- if ctx.Err() != nil {
- return "", ctx.Err()
- }
-
- URL := fmt.Sprintf("courses/%s/folders", f.opt.CourseID)
-
- responseJSON := &StudIPFolders{}
- res, err := f.client.CallJSON(ctx,
- &rest.Opts{Method: "GET", Path: URL}, nil, responseJSON)
- if err != nil {
- return "", err
- }
- defer res.Body.Close()
-
- index := slices.IndexFunc(responseJSON.Data,
- func(e StudIPFoldersData) bool { return e.Attributes.FolderType == "RootFolder" },
- )
-
- if index == -1 {
- return "", errors.New("response doesn't contain a RootFolder")
- }
-
- return responseJSON.Data[index].ID, nil
-}
-
-func (f *Fs) Put(
- ctx context.Context,
- in io.Reader,
- src fs.ObjectInfo,
- options ...fs.OpenOption,
-) (fs.Object, error) {
- return nil, fs.ErrorPermissionDenied
-}
-func (f *Fs) Mkdir(ctx context.Context, dir string) error { return fs.ErrorPermissionDenied }
-func (f *Fs) Rmdir(ctx context.Context, dir string) error { return fs.ErrorPermissionDenied }
-func (f *Fs) Purge(ctx context.Context, dir string) error {
- return fs.ErrorPermissionDenied
-}
-func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
- return nil, fs.ErrorPermissionDenied
-}
-func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
- return nil, fs.ErrorPermissionDenied
-}
-func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string) error {
- return fs.ErrorPermissionDenied
-}
-
-func (o *Object) Fs() fs.Info {
- return o.fs
-}
-
-func (o *Object) String() string {
- if o == nil {
- return "<nil>"
- }
- return o.remote
-}
-
-func (o *Object) Remote() string {
- return o.remote
-}
-
-func (o *Object) Hash(ctx context.Context, r hash.Type) (string, error) {
- return "", hash.ErrUnsupported
-}
-
-func (o *Object) Size() int64 {
- return o.size
-}
-
-// ModTime returns the modification time of the remote http file
-func (o *Object) ModTime(ctx context.Context) time.Time {
- return o.modTime
-}
-
-func (o *Object) SetModTime(ctx context.Context, t time.Time) error { return fs.ErrorNotImplemented }
-func (o *Object) MimeType(ctx context.Context) string { return o.contentType }
+var (
+ _ fs.Fs = &Fs{}
+ _ fs.Object = &Object{}
+ //_ fs.MimeTyper = &Object{}
+ _ fs.Directory = &Directory{}
+)
-func (o *Object) Open(
- ctx context.Context,
- options ...fs.OpenOption,
-) (io.ReadCloser, error) {
- if ctx.Err() != nil {
- return nil, ctx.Err()
+func Assert(cond bool, msg string) {
+ if cond {
+ return
}
- URL := fmt.Sprintf("file-refs/%s/content", o.id)
-
- opts := rest.Opts{Method: "GET", Path: URL}
- var err error
- opts.Options = options
- res, err := o.fs.client.Call(ctx, &opts)
- if err != nil {
- return nil, err
+ if msg == "" {
+ msg = "condition is false"
}
- if res.StatusCode/100 != 2 {
- defer res.Body.Close()
- return nil, fmt.Errorf("HTTP %s", res.Status)
+ pc, file, line, ok := runtime.Caller(1)
+ if !ok {
+ panic("assert failed: " + msg)
}
- return res.Body, nil
-}
-
-func (o *Object) Storable() bool {
- return true
-}
-
-func (o *Object) Update(
- ctx context.Context,
- in io.Reader,
- src fs.ObjectInfo,
- options ...fs.OpenOption,
-) error {
- return fs.ErrorNotImplemented
-}
-
-func (o *Object) Remove(ctx context.Context) error { return fs.ErrorNotImplemented }
-
-type Directory struct {
- fs *Fs
- id string
- name string
- items int64
- modTime time.Time
- remote string
-}
-
-func (dir *Directory) Fs() fs.Info {
- return dir.fs
-}
-
-func (dir *Directory) ID() string {
- return dir.id
-}
-
-func (dir *Directory) Items() int64 {
- return dir.items
-}
-
-func (dir *Directory) String() string {
- return dir.name
-}
-
-func (dir *Directory) ModTime(context.Context) time.Time {
- return dir.modTime
-}
-
-func (dir *Directory) Remote() string {
- return dir.remote
-}
-
-func (dir *Directory) Size() int64 {
- return -1
-}
-
-// Check the interfaces are satisfied
-var (
- _ fs.Info = &Fs{}
- _ fs.Fs = &Fs{}
- _ fs.Object = &Object{}
- _ fs.MimeTyper = &Object{}
- _ fs.Directory = &Directory{}
-)
-func splitPath(p string) []string {
- p = path.Clean(p)
- if p == "/" {
- return []string{}
+ fnName := "<unknown>"
+ if fn := runtime.FuncForPC(pc); fn != nil {
+ fnName = fn.Name()
}
- return strings.Split(p, "/")
+ panic(fmt.Sprintf(
+ "assert failed at %s:%d (%s): %s",
+ filepath.Base(file),
+ line,
+ fnName,
+ msg,
+ ))
}
diff --git a/go.mod b/go.mod
index b28e2e0..c961a09 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/mewsen/rclone-studip-backend-oot
-go 1.25.4
+go 1.26
require github.com/rclone/rclone v1.73.2