diff options
| author | Michael Tews <michael@tews.dev> | 2026-02-28 21:31:04 +0100 |
|---|---|---|
| committer | Michael Tews <michael@tews.dev> | 2026-03-12 15:35:53 +0100 |
| commit | 2673b7d003e853bf7bc7ffc4ba829d9d9d4e4b15 (patch) | |
| tree | 1f043611001f0f795b37ca9015eb8aff932d9245 | |
| parent | ce94f2d69a5f1aab1fc8fc2947f0a6cfd81bb4d1 (diff) | |
feat: write support
Signed-off-by: Michael Tews <michael@tews.dev>
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | backend/studip/directory.go | 45 | ||||
| -rw-r--r-- | backend/studip/filetree.go | 248 | ||||
| -rw-r--r-- | backend/studip/fs.go | 1201 | ||||
| -rw-r--r-- | backend/studip/object.go | 388 | ||||
| -rw-r--r-- | backend/studip/requests.go | 577 | ||||
| -rw-r--r-- | backend/studip/responses.go | 86 | ||||
| -rw-r--r-- | backend/studip/stack.go | 41 | ||||
| -rw-r--r-- | backend/studip/studip.go | 725 | ||||
| -rw-r--r-- | go.mod | 2 |
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, + )) } @@ -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 |
