diff --git a/drivers/onedrive_sharelink/driver.go b/drivers/onedrive_sharelink/driver.go index a975772ac..489cd268f 100644 --- a/drivers/onedrive_sharelink/driver.go +++ b/drivers/onedrive_sharelink/driver.go @@ -44,6 +44,8 @@ type OnedriveSharelink struct { headerMu sync.RWMutex sg singleflight.Group[http.Header] + + listURL string } func (d *OnedriveSharelink) Config() driver.Config { @@ -80,19 +82,49 @@ func (d *OnedriveSharelink) Init(ctx context.Context) error { } d.storeHeaders(h) + // Validate the RootFolderPath format. It is a SharePoint server-relative + // path, so it must be absolute; the document library segment is site + // language dependent and therefore not checked here. + if d.hasCustomRoot() && !strings.HasPrefix(d.RootFolderPath, "/") { + return fmt.Errorf("root_folder_path must be an absolute path, got %q", d.RootFolderPath) + } + return nil } +// hasCustomRoot reports whether the user configured a non-root RootFolderPath. +func (d *OnedriveSharelink) hasCustomRoot() bool { + return d.RootFolderPath != "" && d.RootFolderPath != "/" +} + func (d *OnedriveSharelink) Drop(ctx context.Context) error { return nil } +// relativePath converts the full virtual path from OpenList to a path relative +// to the shared folder's root. When root_folder_path is configured, OpenList +// passes the full path including that prefix. +func (d *OnedriveSharelink) relativePath(virtualPath string) (string, error) { + if !d.hasCustomRoot() { + return virtualPath, nil + } + rel, ok := stripPrefix(virtualPath, d.RootFolderPath) + if !ok { + return "", fmt.Errorf("path %q is outside configured root %q", virtualPath, d.RootFolderPath) + } + return rel, nil +} + func (d *OnedriveSharelink) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { - files, err := d.getFiles(ctx, dir.GetPath()) + relPath, err := d.relativePath(dir.GetPath()) + if err != nil { + return nil, err + } + files, err := d.getFiles(ctx, relPath) if err != nil { return nil, err } - folderSizes, err := d.driveChildrenFolderSizes(ctx, dir.GetPath()) + folderSizes, err := d.driveChildrenFolderSizes(ctx, relPath) if err != nil { log.Warnf("onedrive_sharelink: failed to get folder sizes for %s: %+v", dir.GetPath(), err) } @@ -142,11 +174,15 @@ func (d *OnedriveSharelink) Link(ctx context.Context, file model.Obj, args model } func (d *OnedriveSharelink) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { + relPath, err := d.relativePath(parentDir.GetPath()) + if err != nil { + return err + } token, err := d.getValidDriveAccessToken(ctx) if err != nil { return err } - apiURL := injectAccessToken(d.drivePathAPIURL(parentDir.GetPath())+"/children", token) + apiURL := injectAccessToken(d.drivePathAPIURL(relPath)+"/children", token) body := map[string]any{ "name": dirName, "folder": map[string]any{}, @@ -189,7 +225,11 @@ func (d *OnedriveSharelink) Remove(ctx context.Context, obj model.Obj) error { } func (d *OnedriveSharelink) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error { - info, err := d.createUploadInfo(ctx, stdpath.Join(dstDir.GetPath(), stream.GetName()), stream.GetSize()) + relPath, err := d.relativePath(dstDir.GetPath()) + if err != nil { + return err + } + info, err := d.createUploadInfo(ctx, stdpath.Join(relPath, stream.GetName()), stream.GetSize()) if err != nil { return err } @@ -295,7 +335,11 @@ func (d *OnedriveSharelink) GetDirectUploadInfo(ctx context.Context, tool string if tool != "HttpDirect" { return nil, errs.NotImplement } - return d.createUploadInfo(ctx, stdpath.Join(dstDir.GetPath(), fileName), fileSize) + relPath, err := d.relativePath(dstDir.GetPath()) + if err != nil { + return nil, err + } + return d.createUploadInfo(ctx, stdpath.Join(relPath, fileName), fileSize) } func (d *OnedriveSharelink) createUploadInfo(ctx context.Context, path string, fileSize int64) (*model.HttpDirectUploadInfo, error) { @@ -436,14 +480,49 @@ func (d *OnedriveSharelink) uploadSessionChunk(ctx context.Context, uploadURL st } func (d *OnedriveSharelink) drivePathAPIURL(path string) string { - drivePath := stdpath.Join(d.driveRootPath, path) - drivePath = utils.FixAndCleanPath(drivePath) + base := d.driveRootPath + // When RootFolderPath is configured, the base drive root needs to take it + // into account so the API targets the correct subfolder. + if d.hasCustomRoot() { + base = d.effectiveDriveRootPath() + } + drivePath := utils.FixAndCleanPath(stdpath.Join(base, path)) if drivePath == "/" { return d.DriveURL + "/root" } return fmt.Sprintf("%s/root:%s:", d.DriveURL, utils.EncodePath(drivePath, true)) } +// effectiveDriveRootPath computes the drive-relative root path from the +// user-configured RootFolderPath. RootFolderPath is a SharePoint server-relative +// path (e.g. /personal/user/Documents/subfolder); this method extracts the +// portion beyond the document library root (e.g. /subfolder). +func (d *OnedriveSharelink) effectiveDriveRootPath() string { + if d.listURL == "" || !d.hasCustomRoot() { + return d.driveRootPath + } + if rel, ok := stripPrefix(d.RootFolderPath, d.listURL); ok { + return rel + } + log.Warnf("onedrive_sharelink: RootFolderPath %q is not under listURL %q", d.RootFolderPath, d.listURL) + return d.driveRootPath +} + +// stripPrefix removes prefix from path when path equals prefix or is nested +// under it, matching on the path separator to avoid /foo matching /foobar. +// It reports whether the prefix matched. +func stripPrefix(path, prefix string) (string, bool) { + path = utils.FixAndCleanPath(path) + prefix = utils.FixAndCleanPath(prefix) + if path == prefix { + return "/", true + } + if strings.HasPrefix(path, prefix+"/") { + return utils.FixAndCleanPath(path[len(prefix):]), true + } + return path, false +} + func injectAccessToken(rawURL, token string) string { if token == "" { return rawURL @@ -561,6 +640,7 @@ func (d *OnedriveSharelink) refreshDriveContextFromRedirect(ctx context.Context, d.DriveAccessToken = ctxInfo.DriveInfo.DriveAccessToken d.DriveTokenTime = time.Now().Unix() d.driveRootPath = rootPath + d.listURL = ctxInfo.ListURL d.headerMu.Unlock() return nil } diff --git a/drivers/onedrive_sharelink/driver_test.go b/drivers/onedrive_sharelink/driver_test.go new file mode 100644 index 000000000..01af1d215 --- /dev/null +++ b/drivers/onedrive_sharelink/driver_test.go @@ -0,0 +1,97 @@ +package onedrive_sharelink + +import "testing" + +func TestRelativePath(t *testing.T) { + root := "/personal/user/Documents/sub" + tests := []struct { + name string + root string + virtualPath string + want string + wantErr bool + }{ + {"empty root", "", "/a/b", "/a/b", false}, + {"slash root", "/", "/a/b", "/a/b", false}, + {"exact root", root, root, "/", false}, + {"child", root, root + "/a", "/a", false}, + {"grandchild", root, root + "/a/b/c", "/a/b/c", false}, + {"dirty child", root, root + "/a/../b", "/b", false}, + {"non-english library", "/personal/user/文档/sub", "/personal/user/文档/sub/a", "/a", false}, + {"prefix collision", root, root + "A/x", "", true}, + {"outside root", root, "/other", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &OnedriveSharelink{} + d.RootFolderPath = tt.root + got, err := d.relativePath(tt.virtualPath) + if (err != nil) != tt.wantErr { + t.Fatalf("relativePath(%q) error = %v, wantErr %v", tt.virtualPath, err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Errorf("relativePath(%q) = %q, want %q", tt.virtualPath, got, tt.want) + } + }) + } +} + +func TestEffectiveDriveRootPath(t *testing.T) { + list := "/personal/user/Documents" + driveRoot := "/" + tests := []struct { + name string + root string + list string + want string + }{ + {"no listURL", list, "", driveRoot}, + {"empty root", "", list, driveRoot}, + {"slash root", "/", list, driveRoot}, + {"exact list", list, list, "/"}, + {"child", list + "/sub", list, "/sub"}, + {"nested child", list + "/sub/a", list, "/sub/a"}, + {"prefix collision", list + "A", list, driveRoot}, + {"outside list", "/other", list, driveRoot}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &OnedriveSharelink{} + d.RootFolderPath = tt.root + d.listURL = tt.list + d.driveRootPath = driveRoot + if got := d.effectiveDriveRootPath(); got != tt.want { + t.Errorf("effectiveDriveRootPath() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDrivePathAPIURL(t *testing.T) { + const drive = "https://d.example.com" + tests := []struct { + name string + root string + list string + path string + want string + }{ + {"no root, drive root", "", "", "/", drive + "/root"}, + {"no root, child", "", "", "/a", drive + "/root:/a:"}, + {"root equals list", "/personal/user/Documents", "/personal/user/Documents", "/a", drive + "/root:/a:"}, + {"root under list, child", "/personal/user/Documents/sub", "/personal/user/Documents", "/a", drive + "/root:/sub/a:"}, + {"custom library, child", "/sites/x/Shared Documents/sub", "/sites/x/Shared Documents", "/a", drive + "/root:/sub/a:"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &OnedriveSharelink{} + d.DriveURL = drive + d.RootFolderPath = tt.root + d.listURL = tt.list + d.driveRootPath = "/" + if got := d.drivePathAPIURL(tt.path); got != tt.want { + t.Errorf("drivePathAPIURL(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} diff --git a/drivers/onedrive_sharelink/util.go b/drivers/onedrive_sharelink/util.go index a0f7450d3..d7f8f6107 100644 --- a/drivers/onedrive_sharelink/util.go +++ b/drivers/onedrive_sharelink/util.go @@ -13,6 +13,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/drivers/base" internalNet "github.com/OpenListTeam/OpenList/v4/internal/net" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" log "github.com/sirupsen/logrus" "golang.org/x/net/html" ) @@ -260,9 +261,15 @@ func (d *OnedriveSharelink) getFiles(ctx context.Context, path string) ([]Item, if err != nil { return nil, err } - log.Debugln("rootFolder:", rootFolder) - // Extract the relative path up to and including "Documents" + // If the user configured a root_folder_path, use it as the initial root + // folder so the GraphQL query targets the correct subdirectory. The + // document library root is still derived from the share link itself, so it + // does not depend on the configured path. relativePath := strings.Split(rootFolder, "Documents")[0] + "Documents" + if d.hasCustomRoot() { + rootFolder = utils.FixAndCleanPath(d.RootFolderPath) + } + log.Debugln("rootFolder:", rootFolder) // URL encode the relative path relativeUrl := url.QueryEscape(relativePath)