From 0c690b7d152f691baa874f3c6cbe14d79f8f7396 Mon Sep 17 00:00:00 2001 From: AkashiCoin Date: Sat, 24 Oct 2026 18:18:57 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(drivers/onedrive=5Fapp):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=8C=82=E8=BD=BD=20SharePoint=20=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `is_sharepoint` 与 `site_id` 两个配置项,开启后走 `/sites/{siteId}/drive` 而不是 `/users/{email}/drive` - 新增 `NormalizeSiteID`,接受 `siteId,siteGuid,webGuid`、`host:/sites/name`、站点 URL 和站点集合根 URL 四种写法 - 路径形式的 site 标识在 `Init` 阶段解析成 Graph 的逗号形式并回写存储,因为 `/sites/{path}:/` 后面不能继续拼 `/drive` - `Init` 对 SharePoint 模式校验 `site_id`,非 SharePoint 模式校验 `email` 非空 - 配额查询 `getDrive` 同步区分两种模式 - 补充 `NormalizeSiteID` 与 URL 拼接的单元测试 Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- drivers/onedrive_app/driver.go | 17 +++++- drivers/onedrive_app/meta.go | 2 + drivers/onedrive_app/util.go | 76 ++++++++++++++++++++++++- drivers/onedrive_app/util_test.go | 92 +++++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 drivers/onedrive_app/util_test.go diff --git a/drivers/onedrive_app/driver.go b/drivers/onedrive_app/driver.go index 063552308e..bb5d6838f5 100644 --- a/drivers/onedrive_app/driver.go +++ b/drivers/onedrive_app/driver.go @@ -36,7 +36,22 @@ func (d *OnedriveAPP) Init(ctx context.Context) error { if d.ChunkSize < 1 { d.ChunkSize = 5 } - return d.accessToken() + if d.IsSharepoint && d.SiteID == "" { + return fmt.Errorf("site_id is required when is_sharepoint is enabled") + } + if !d.IsSharepoint && d.Email == "" { + return fmt.Errorf("email is required when is_sharepoint is disabled") + } + if d.IsSharepoint { + d.SiteID = NormalizeSiteID(d.SiteID) + } + if err := d.accessToken(); err != nil { + return err + } + if d.IsSharepoint { + return d.resolveSiteID() + } + return nil } func (d *OnedriveAPP) Drop(ctx context.Context) error { diff --git a/drivers/onedrive_app/meta.go b/drivers/onedrive_app/meta.go index c3f0c02ecd..0a600caf42 100644 --- a/drivers/onedrive_app/meta.go +++ b/drivers/onedrive_app/meta.go @@ -12,6 +12,8 @@ type Addition struct { ClientSecret string `json:"client_secret" required:"true"` TenantID string `json:"tenant_id"` Email string `json:"email"` + IsSharepoint bool `json:"is_sharepoint" default:"false" help:"Mount a SharePoint site's default document library instead of a user's OneDrive"` + SiteID string `json:"site_id" help:"SharePoint site ID, e.g. contoso.sharepoint.com,, or contoso.sharepoint.com:/sites/"` ChunkSize int64 `json:"chunk_size" type:"number" default:"5"` CustomHost string `json:"custom_host" help:"Custom host for onedrive download link"` DisableDiskUsage bool `json:"disable_disk_usage" default:"false"` diff --git a/drivers/onedrive_app/util.go b/drivers/onedrive_app/util.go index 574afb381e..6cb8a0a126 100644 --- a/drivers/onedrive_app/util.go +++ b/drivers/onedrive_app/util.go @@ -6,7 +6,9 @@ import ( "fmt" "io" "net/http" + "net/url" stdpath "path" + "strings" "time" "github.com/OpenListTeam/OpenList/v4/drivers/base" @@ -40,16 +42,79 @@ var onedriveHostMap = map[string]Host{ }, } +// NormalizeSiteID converts any accepted SharePoint site identifier into the form +// expected by the /sites/{site-id} segment. +// +// Accepted inputs: +// +// contoso.sharepoint.com,, +// contoso.sharepoint.com:/sites/ [/sites/:] +// https://contoso.sharepoint.com/sites/ +// https://contoso.sharepoint.com (site collection root) +// +// The path form must keep its trailing colon, otherwise Graph rejects the URL. +// A path-form identifier still has to be resolved to the comma form before it can +// be followed by /drive; see resolveSiteID. +func NormalizeSiteID(siteID string) string { + siteID = strings.TrimSpace(siteID) + if siteID == "" { + return "" + } + if strings.HasPrefix(siteID, "https://") || strings.HasPrefix(siteID, "http://") { + if u, err := url.Parse(siteID); err == nil && u.Host != "" { + siteID = u.Host + ":" + strings.TrimSuffix(u.Path, "/") + } + } + if strings.Contains(siteID, ":/") { + siteID = strings.TrimSuffix(siteID, "/") + if !strings.HasSuffix(siteID, ":") { + siteID += ":" + } + } + return siteID +} + +// resolveSiteID turns a site path identifier into the opaque comma-separated site +// id, since Graph only accepts /sites/{path}:/ as a terminal segment — it cannot be +// followed by /drive. Already-resolved ids are left untouched. +func (d *OnedriveAPP) resolveSiteID() error { + siteID := NormalizeSiteID(d.SiteID) + if strings.Contains(siteID, ",") { + d.SiteID = siteID + return nil + } + host := onedriveHostMap[d.Region] + var resp struct { + Id string `json:"id"` + } + _, err := d.Request(fmt.Sprintf("%s/v1.0/sites/%s?$select=id", host.Api, siteID), http.MethodGet, nil, &resp) + if err != nil { + return err + } + if resp.Id == "" { + return fmt.Errorf("failed to resolve sharepoint site %q", d.SiteID) + } + d.SiteID = resp.Id + op.MustSaveDriverStorage(d) + return nil +} + func (d *OnedriveAPP) GetMetaUrl(auth bool, path string) string { host := onedriveHostMap[d.Region] path = utils.EncodePath(path, true) if auth { return host.Oauth } + var base string + if d.IsSharepoint { + base = fmt.Sprintf("%s/v1.0/sites/%s/drive/root", host.Api, NormalizeSiteID(d.SiteID)) + } else { + base = fmt.Sprintf("%s/v1.0/users/%s/drive/root", host.Api, d.Email) + } if path == "/" || path == "\\" { - return fmt.Sprintf("%s/v1.0/users/%s/drive/root", host.Api, d.Email) + return base } - return fmt.Sprintf("%s/v1.0/users/%s/drive/root:%s:", host.Api, d.Email, path) + return base + ":" + path + ":" } func (d *OnedriveAPP) accessToken() error { @@ -213,7 +278,12 @@ func (d *OnedriveAPP) upBig(ctx context.Context, dstDir model.Obj, stream model. func (d *OnedriveAPP) getDrive(ctx context.Context) (*DriveResp, error) { host, _ := onedriveHostMap[d.Region] - api := fmt.Sprintf("%s/v1.0/users/%s/drive", host.Api, d.Email) + var api string + if d.IsSharepoint { + api = fmt.Sprintf("%s/v1.0/sites/%s/drive", host.Api, NormalizeSiteID(d.SiteID)) + } else { + api = fmt.Sprintf("%s/v1.0/users/%s/drive", host.Api, d.Email) + } var resp DriveResp _, err := d.Request(api, http.MethodGet, func(req *resty.Request) { req.SetContext(ctx) diff --git a/drivers/onedrive_app/util_test.go b/drivers/onedrive_app/util_test.go new file mode 100644 index 0000000000..e06f447258 --- /dev/null +++ b/drivers/onedrive_app/util_test.go @@ -0,0 +1,92 @@ +package onedrive_app + +import "testing" + +func TestNormalizeSiteID(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "already resolved", + in: "contoso.sharepoint.com,11111111-2222-3333-4444-555555555555,66666666-7777-8888-9999-aaaaaaaaaaaa", + want: "contoso.sharepoint.com,11111111-2222-3333-4444-555555555555,66666666-7777-8888-9999-aaaaaaaaaaaa", + }, + { + name: "web url", + in: "https://contoso.sharepoint.com/sites/team", + want: "contoso.sharepoint.com:/sites/team:", + }, + { + name: "web url with trailing slash", + in: "https://contoso.sharepoint.com/sites/team/", + want: "contoso.sharepoint.com:/sites/team:", + }, + { + name: "web url at site collection root", + in: "https://contoso.sharepoint.com/", + want: "contoso.sharepoint.com:", + }, + { + name: "path form without colon", + in: "contoso.sharepoint.com:/sites/team", + want: "contoso.sharepoint.com:/sites/team:", + }, + { + name: "path form with colon and slash", + in: "contoso.sharepoint.com:/sites/team/:", + want: "contoso.sharepoint.com:/sites/team/:", + }, + { + name: "surrounding whitespace", + in: " contoso.sharepoint.com:/sites/team: ", + want: "contoso.sharepoint.com:/sites/team:", + }, + { + name: "empty", + in: "", + want: "", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := NormalizeSiteID(c.in); got != c.want { + t.Fatalf("NormalizeSiteID(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestGetMetaUrlSharepoint(t *testing.T) { + d := &OnedriveAPP{ + Addition: Addition{ + Region: "global", + IsSharepoint: true, + SiteID: "contoso.sharepoint.com,11111111-2222-3333-4444-555555555555,66666666-7777-8888-9999-aaaaaaaaaaaa", + }, + } + const wantRoot = "https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com,11111111-2222-3333-4444-555555555555,66666666-7777-8888-9999-aaaaaaaaaaaa/drive/root" + if got := d.GetMetaUrl(false, "/"); got != wantRoot { + t.Fatalf("root url = %q, want %q", got, wantRoot) + } + if got, want := d.GetMetaUrl(false, "/a b.txt"), wantRoot+":/a%20b.txt:"; got != want { + t.Fatalf("file url = %q, want %q", got, want) + } +} + +func TestGetMetaUrlUserDrive(t *testing.T) { + d := &OnedriveAPP{ + Addition: Addition{ + Region: "global", + Email: "user@contoso.com", + }, + } + const wantRoot = "https://graph.microsoft.com/v1.0/users/user@contoso.com/drive/root" + if got := d.GetMetaUrl(false, "/"); got != wantRoot { + t.Fatalf("root url = %q, want %q", got, wantRoot) + } + if got, want := d.GetMetaUrl(false, "/dir"), wantRoot+":/dir:"; got != want { + t.Fatalf("dir url = %q, want %q", got, want) + } +} From c8674640c9250a5f2996b3db8f64f37b8bcdcc64 Mon Sep 17 00:00:00 2001 From: AkashiCoin Date: Sat, 24 Oct 2026 18:19:10 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(drivers/onedrive=5Fapp):=20=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E5=B9=B6=E7=B2=BE=E7=AE=80=20site=5Fid=20=E7=9A=84?= =?UTF-8?q?=E5=B8=AE=E5=8A=A9=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 说明裸域名会解析成站点集合根站点,其默认文档库通常为空 - 补充完整站点 URL 也会被解析成 ID 形式 - 合并为两句话,缩短表单提示气泡长度 Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- drivers/onedrive_app/meta.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/onedrive_app/meta.go b/drivers/onedrive_app/meta.go index 0a600caf42..3a9a2b2618 100644 --- a/drivers/onedrive_app/meta.go +++ b/drivers/onedrive_app/meta.go @@ -13,7 +13,7 @@ type Addition struct { TenantID string `json:"tenant_id"` Email string `json:"email"` IsSharepoint bool `json:"is_sharepoint" default:"false" help:"Mount a SharePoint site's default document library instead of a user's OneDrive"` - SiteID string `json:"site_id" help:"SharePoint site ID, e.g. contoso.sharepoint.com,, or contoso.sharepoint.com:/sites/"` + SiteID string `json:"site_id" help:"One of: ,,; :/sites/; or a full site URL. Point it at the site that owns the library — a bare host resolves to the empty root-site library"` ChunkSize int64 `json:"chunk_size" type:"number" default:"5"` CustomHost string `json:"custom_host" help:"Custom host for onedrive download link"` DisableDiskUsage bool `json:"disable_disk_usage" default:"false"` From 8d340912476c3288500708d562cc330a1ee2b962 Mon Sep 17 00:00:00 2001 From: AkashiCoin Date: Mon, 26 Oct 2026 19:27:59 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(drivers/onedrive=5Fapp):=20=E6=A0=B9?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E6=A0=87=E8=AF=86=E4=B8=8D=E5=86=8D=E6=8B=BC?= =?UTF-8?q?=E6=8E=A5=E5=B0=BE=E5=86=92=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `NormalizeSiteID` 对空站点路径走 `/sites/{hostname}` 主机名寻址,只有存在站点子路径时才生成 `/sites/{hostname}:/{path}:` - 统一 `host:/path/:` 这类多重尾字符为单个冒号 - 补充裸主机名、带尾斜杠主机名、`host:/` 三种输入的用例 Co-authored-by: Claude <81847+claude@users.noreply.github.com> --- drivers/onedrive_app/util.go | 28 ++++++++++++++++++---------- drivers/onedrive_app/util_test.go | 24 ++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/drivers/onedrive_app/util.go b/drivers/onedrive_app/util.go index 6cb8a0a126..896b95b74a 100644 --- a/drivers/onedrive_app/util.go +++ b/drivers/onedrive_app/util.go @@ -52,7 +52,9 @@ var onedriveHostMap = map[string]Host{ // https://contoso.sharepoint.com/sites/ // https://contoso.sharepoint.com (site collection root) // -// The path form must keep its trailing colon, otherwise Graph rejects the URL. +// Graph has two addressing forms: /sites/{hostname} for the root site of a host, and +// /sites/{hostname}:/{path}: for a site below it. Only the path form carries a colon, +// so an empty path must not produce one. // A path-form identifier still has to be resolved to the comma form before it can // be followed by /drive; see resolveSiteID. func NormalizeSiteID(siteID string) string { @@ -60,18 +62,24 @@ func NormalizeSiteID(siteID string) string { if siteID == "" { return "" } - if strings.HasPrefix(siteID, "https://") || strings.HasPrefix(siteID, "http://") { - if u, err := url.Parse(siteID); err == nil && u.Host != "" { - siteID = u.Host + ":" + strings.TrimSuffix(u.Path, "/") + var host, path string + switch { + case strings.HasPrefix(siteID, "https://"), strings.HasPrefix(siteID, "http://"): + u, err := url.Parse(siteID) + if err != nil || u.Host == "" { + return siteID } + host, path = u.Host, u.Path + case strings.Contains(siteID, ":/"): + host, path, _ = strings.Cut(siteID, ":/") + default: + // 已解析的复合 ID 或纯主机名 + return strings.TrimSuffix(siteID, "/") } - if strings.Contains(siteID, ":/") { - siteID = strings.TrimSuffix(siteID, "/") - if !strings.HasSuffix(siteID, ":") { - siteID += ":" - } + if path = strings.Trim(path, "/:"); path != "" { + return host + ":/" + path + ":" } - return siteID + return host } // resolveSiteID turns a site path identifier into the opaque comma-separated site diff --git a/drivers/onedrive_app/util_test.go b/drivers/onedrive_app/util_test.go index e06f447258..558e200cfc 100644 --- a/drivers/onedrive_app/util_test.go +++ b/drivers/onedrive_app/util_test.go @@ -26,7 +26,22 @@ func TestNormalizeSiteID(t *testing.T) { { name: "web url at site collection root", in: "https://contoso.sharepoint.com/", - want: "contoso.sharepoint.com:", + want: "contoso.sharepoint.com", + }, + { + name: "web url without trailing slash at root", + in: "https://contoso.sharepoint.com", + want: "contoso.sharepoint.com", + }, + { + name: "bare hostname", + in: "contoso.sharepoint.com", + want: "contoso.sharepoint.com", + }, + { + name: "hostname with trailing slash", + in: "contoso.sharepoint.com/", + want: "contoso.sharepoint.com", }, { name: "path form without colon", @@ -36,7 +51,12 @@ func TestNormalizeSiteID(t *testing.T) { { name: "path form with colon and slash", in: "contoso.sharepoint.com:/sites/team/:", - want: "contoso.sharepoint.com:/sites/team/:", + want: "contoso.sharepoint.com:/sites/team:", + }, + { + name: "path form of host root", + in: "contoso.sharepoint.com:/", + want: "contoso.sharepoint.com", }, { name: "surrounding whitespace",