From 48bb9ec61c1b0a59c11796580b5b278a0645c427 Mon Sep 17 00:00:00 2001 From: Mythic3011 <42365807+mythic3011@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:40:51 +0800 Subject: [PATCH 1/6] fix(google_drive): support export of Google Workspace native files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FileMeta struct to capture mimeType and capabilities/canDownload from the files.get metadata response already performed in Link() - Add downloadStrategy / resolveDownloadStrategy to map source MIME types to the correct Drive endpoint: - Binary/uploaded files → files.get?alt=media&acknowledgeAbuse=true - Google Docs/Sheets/Slides/Drawing/Script → files.export?mimeType=... - Non-downloadable Workspace types (folder, shortcut, form, site, map, vid, unknown) → explicit error instead of a misleading 403 - Add FileLinkFields constant for the fields param used in Link() - Add MIME type constants and googleWorkspaceExports / googleWorkspaceUnsupported maps for centralized, data-driven routing - Fix pre-existing go vet warnings: non-constant format strings in refreshToken() - Add util_test.go covering strategy resolution for 21 cases: media download, export, unsupported types, and URL query encoding --- drivers/google_drive/driver.go | 39 ++++++++++- drivers/google_drive/types.go | 60 ++++++++++++++++ drivers/google_drive/util.go | 40 ++++++++++- drivers/google_drive/util_test.go | 112 ++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 drivers/google_drive/util_test.go diff --git a/drivers/google_drive/driver.go b/drivers/google_drive/driver.go index 94ef854f2f..da55c5d450 100644 --- a/drivers/google_drive/driver.go +++ b/drivers/google_drive/driver.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "net/url" "strconv" "github.com/OpenListTeam/OpenList/v4/drivers/base" @@ -52,13 +53,45 @@ func (d *GoogleDrive) List(ctx context.Context, dir model.Obj, args model.ListAr } func (d *GoogleDrive) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { - url := fmt.Sprintf("https://www.googleapis.com/drive/v3/files/%s?includeItemsFromAllDrives=true&supportsAllDrives=true", file.GetID()) - _, err := d.request(url, http.MethodGet, nil, nil) + fileID := file.GetID() + metaURL := fmt.Sprintf("https://www.googleapis.com/drive/v3/files/%s", fileID) + + var meta FileMeta + _, err := d.request(metaURL, http.MethodGet, func(req *resty.Request) { + req.SetQueryParam("fields", FileLinkFields) + }, &meta) + if err != nil { + return nil, err + } + + if !meta.Capabilities.CanDownload { + return nil, fmt.Errorf("file %q cannot be downloaded: download capability is not granted", fileID) + } + + strategy, err := resolveDownloadStrategy(meta.MimeType) if err != nil { return nil, err } + + var downloadURL string + switch strategy.Kind { + case kindMedia: + q := url.Values{} + q.Set("alt", "media") + q.Set("acknowledgeAbuse", "true") + q.Set("includeItemsFromAllDrives", "true") + q.Set("supportsAllDrives", "true") + downloadURL = metaURL + "?" + q.Encode() + case kindExport: + q := url.Values{} + q.Set("mimeType", strategy.ExportMIME) + q.Set("includeItemsFromAllDrives", "true") + q.Set("supportsAllDrives", "true") + downloadURL = metaURL + "/export?" + q.Encode() + } + link := model.Link{ - URL: url + "&alt=media&acknowledgeAbuse=true", + URL: downloadURL, Header: http.Header{ "Authorization": []string{"Bearer " + d.AccessToken}, }, diff --git a/drivers/google_drive/types.go b/drivers/google_drive/types.go index 5e71f305ff..cbedb9c784 100644 --- a/drivers/google_drive/types.go +++ b/drivers/google_drive/types.go @@ -9,6 +9,66 @@ import ( log "github.com/sirupsen/logrus" ) +// Google Workspace source MIME types. +const ( + mimeTypeGoogleDoc = "application/vnd.google-apps.document" + mimeTypeGoogleSheet = "application/vnd.google-apps.spreadsheet" + mimeTypeGoogleSlides = "application/vnd.google-apps.presentation" + mimeTypeGoogleDrawing = "application/vnd.google-apps.drawing" + mimeTypeGoogleScript = "application/vnd.google-apps.script" + mimeTypeGoogleFolder = "application/vnd.google-apps.folder" + mimeTypeGoogleShortcut = "application/vnd.google-apps.shortcut" + mimeTypeGoogleForm = "application/vnd.google-apps.form" + mimeTypeGoogleSite = "application/vnd.google-apps.site" + mimeTypeGoogleMap = "application/vnd.google-apps.map" + mimeTypeGoogleVid = "application/vnd.google-apps.vid" +) + +// Export target MIME types. +const ( + mimeTypeDocx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + mimeTypeXlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + mimeTypePptx = "application/vnd.openxmlformats-officedocument.presentationml.presentation" + mimeTypePDF = "application/pdf" + mimeTypeScriptJSON = "application/vnd.google-apps.script+json" +) + +// exportFormat holds the export target MIME type and the resulting file extension. +type exportFormat struct { + MIME string + Ext string +} + +// googleWorkspaceExports maps exportable Google Workspace source MIME types to their export targets. +var googleWorkspaceExports = map[string]exportFormat{ + mimeTypeGoogleDoc: {MIME: mimeTypeDocx, Ext: ".docx"}, + mimeTypeGoogleSheet: {MIME: mimeTypeXlsx, Ext: ".xlsx"}, + mimeTypeGoogleSlides: {MIME: mimeTypePptx, Ext: ".pptx"}, + mimeTypeGoogleDrawing: {MIME: mimeTypePDF, Ext: ".pdf"}, + mimeTypeGoogleScript: {MIME: mimeTypeScriptJSON, Ext: ".json"}, +} + +// googleWorkspaceUnsupported lists Google Workspace MIME types that cannot be downloaded +// via files.get or files.export, with a human-readable reason. +var googleWorkspaceUnsupported = map[string]string{ + mimeTypeGoogleFolder: "folders cannot be downloaded", + mimeTypeGoogleShortcut: "shortcuts must be resolved before downloading", + mimeTypeGoogleForm: "Google Forms are not downloadable via the Drive API", + mimeTypeGoogleSite: "Google Sites are not downloadable via the Drive API", + mimeTypeGoogleMap: "Google Maps are not downloadable via the Drive API", + // Google Vids require the files.download long-running-operation flow which is not + // yet supported. See https://developers.google.com/drive/api/reference/rest/v3/files/download + mimeTypeGoogleVid: "Google Vids require the Drive files.download long-running-operation flow which is not yet implemented", +} + +// FileMeta holds the fields we need from a files.get metadata response inside Link(). +type FileMeta struct { + MimeType string `json:"mimeType"` + Capabilities struct { + CanDownload bool `json:"canDownload"` + } `json:"capabilities"` +} + type TokenError struct { Error string `json:"error"` ErrorDescription string `json:"error_description"` diff --git a/drivers/google_drive/util.go b/drivers/google_drive/util.go index 042abafa44..96ffdcb810 100644 --- a/drivers/google_drive/util.go +++ b/drivers/google_drive/util.go @@ -31,10 +31,44 @@ import ( const ( // File list query fields FilesListFields = "files(id,name,mimeType,size,modifiedTime,createdTime,thumbnailLink,shortcutDetails,md5Checksum,sha1Checksum,sha256Checksum),nextPageToken" - // Single file query fields + // Single file query fields (used when fetching shortcut target metadata) FileInfoFields = "id,name,mimeType,size,md5Checksum,sha1Checksum,sha256Checksum" + // Fields fetched during Link() to decide the download strategy + FileLinkFields = "mimeType,capabilities/canDownload" ) +// downloadKind distinguishes the two Drive download strategies. +type downloadKind int + +const ( + kindMedia downloadKind = iota // files.get?alt=media + kindExport // files.export?mimeType=... +) + +// downloadStrategy describes which endpoint and parameters to use for a given file. +type downloadStrategy struct { + Kind downloadKind + ExportMIME string + Ext string +} + +// resolveDownloadStrategy returns the correct download strategy for the given Drive source +// MIME type. It returns an error for Google Workspace types that cannot be downloaded. +func resolveDownloadStrategy(sourceMIME string) (downloadStrategy, error) { + if ef, ok := googleWorkspaceExports[sourceMIME]; ok { + return downloadStrategy{Kind: kindExport, ExportMIME: ef.MIME, Ext: ef.Ext}, nil + } + if reason, ok := googleWorkspaceUnsupported[sourceMIME]; ok { + return downloadStrategy{}, fmt.Errorf("unsupported Google Workspace file type %q: %s", sourceMIME, reason) + } + // Any remaining application/vnd.google-apps.* type is not in our allow-list. + if len(sourceMIME) > 28 && sourceMIME[:28] == "application/vnd.google-apps." { + return downloadStrategy{}, fmt.Errorf("unsupported Google Workspace file type: %q", sourceMIME) + } + // All other MIME types (binary/uploaded files) use the media download endpoint. + return downloadStrategy{Kind: kindMedia}, nil +} + type googleDriveServiceAccount struct { // Type string `json:"type"` // ProjectID string `json:"project_id"` @@ -170,7 +204,7 @@ func (d *GoogleDrive) refreshToken() error { } log.Debug(res.String()) if e.Error != "" { - return fmt.Errorf(e.Error) + return fmt.Errorf("%s", e.Error) } d.AccessToken = resp.AccessToken return nil @@ -192,7 +226,7 @@ func (d *GoogleDrive) refreshToken() error { } log.Debug(res.String()) if e.Error != "" { - return fmt.Errorf(e.Error) + return fmt.Errorf("%s", e.Error) } d.AccessToken = resp.AccessToken return nil diff --git a/drivers/google_drive/util_test.go b/drivers/google_drive/util_test.go new file mode 100644 index 0000000000..0aacc7f5eb --- /dev/null +++ b/drivers/google_drive/util_test.go @@ -0,0 +1,112 @@ +package google_drive + +import ( + "net/url" + "strings" + "testing" +) + +func TestResolveDownloadStrategy_Media(t *testing.T) { + cases := []string{ + "application/pdf", + "image/jpeg", + "image/png", + "application/zip", + "video/mp4", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/octet-stream", + } + for _, mime := range cases { + t.Run(mime, func(t *testing.T) { + s, err := resolveDownloadStrategy(mime) + if err != nil { + t.Fatalf("unexpected error for %q: %v", mime, err) + } + if s.Kind != kindMedia { + t.Errorf("expected kindMedia for %q, got %v", mime, s.Kind) + } + }) + } +} + +func TestResolveDownloadStrategy_Export(t *testing.T) { + cases := []struct { + src string + wantMIME string + wantExt string + }{ + {mimeTypeGoogleDoc, mimeTypeDocx, ".docx"}, + {mimeTypeGoogleSheet, mimeTypeXlsx, ".xlsx"}, + {mimeTypeGoogleSlides, mimeTypePptx, ".pptx"}, + {mimeTypeGoogleDrawing, mimeTypePDF, ".pdf"}, + {mimeTypeGoogleScript, mimeTypeScriptJSON, ".json"}, + } + for _, c := range cases { + t.Run(c.src, func(t *testing.T) { + s, err := resolveDownloadStrategy(c.src) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.Kind != kindExport { + t.Errorf("expected kindExport, got %v", s.Kind) + } + if s.ExportMIME != c.wantMIME { + t.Errorf("ExportMIME: got %q, want %q", s.ExportMIME, c.wantMIME) + } + if s.Ext != c.wantExt { + t.Errorf("Ext: got %q, want %q", s.Ext, c.wantExt) + } + }) + } +} + +func TestResolveDownloadStrategy_Unsupported(t *testing.T) { + cases := []string{ + mimeTypeGoogleFolder, + mimeTypeGoogleShortcut, + mimeTypeGoogleForm, + mimeTypeGoogleSite, + mimeTypeGoogleMap, + mimeTypeGoogleVid, + "application/vnd.google-apps.unknown-future-type", + } + for _, mime := range cases { + t.Run(mime, func(t *testing.T) { + _, err := resolveDownloadStrategy(mime) + if err == nil { + t.Errorf("expected error for unsupported type %q, got nil", mime) + } + }) + } +} + +// TestExportURLEncoding verifies that MIME types with special characters are +// properly percent-encoded in the export URL query string. +func TestExportURLEncoding(t *testing.T) { + cases := []struct { + mime string + want string + }{ + { + mimeTypeDocx, + // / and + must be encoded + url.QueryEscape(mimeTypeDocx), + }, + { + mimeTypeScriptJSON, + url.QueryEscape(mimeTypeScriptJSON), + }, + } + for _, c := range cases { + t.Run(c.mime, func(t *testing.T) { + q := url.Values{} + q.Set("mimeType", c.mime) + encoded := q.Encode() + if !strings.Contains(encoded, c.want) { + t.Errorf("encoded %q does not contain %q", encoded, c.want) + } + }) + } +} From d741c3d8085cc6d7fe30e695dc326aadbbd26a16 Mon Sep 17 00:00:00 2001 From: Mythic3011 <42365807+mythic3011@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:40:51 +0800 Subject: [PATCH 2/6] fix(google_drive): refine workspace export routing - Remove invalid params from files.export URL: drop includeItemsFromAllDrives and supportsAllDrives; export endpoint takes only mimeType - Extract buildDownloadURL() pure helper; use it from Link() - Remove dead Ext field from exportFormat and downloadStrategy; simplify googleWorkspaceExports to map[string]string - Replace manual MIME-prefix slice check with strings.HasPrefix - Reword unsupported-type errors to describe driver limitation rather than API impossibility (form, site, map, vid) - Add 10 MB export size cap comment near files.export path - Revert unrelated go vet cleanup in refreshToken() (fmt.Errorf format string; leave for a separate PR) - Replace TestExportURLEncoding with TestBuildDownloadURL; assert scheme, host, path, query values via net/url.Parse; verify export URLs exclude media-only params --- drivers/google_drive/driver.go | 20 +----- drivers/google_drive/types.go | 39 +++++------ drivers/google_drive/util.go | 44 ++++++++++-- drivers/google_drive/util_test.go | 111 ++++++++++++++++++++---------- 4 files changed, 131 insertions(+), 83 deletions(-) diff --git a/drivers/google_drive/driver.go b/drivers/google_drive/driver.go index da55c5d450..67c86efc5a 100644 --- a/drivers/google_drive/driver.go +++ b/drivers/google_drive/driver.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "net/url" "strconv" "github.com/OpenListTeam/OpenList/v4/drivers/base" @@ -73,25 +72,8 @@ func (d *GoogleDrive) Link(ctx context.Context, file model.Obj, args model.LinkA return nil, err } - var downloadURL string - switch strategy.Kind { - case kindMedia: - q := url.Values{} - q.Set("alt", "media") - q.Set("acknowledgeAbuse", "true") - q.Set("includeItemsFromAllDrives", "true") - q.Set("supportsAllDrives", "true") - downloadURL = metaURL + "?" + q.Encode() - case kindExport: - q := url.Values{} - q.Set("mimeType", strategy.ExportMIME) - q.Set("includeItemsFromAllDrives", "true") - q.Set("supportsAllDrives", "true") - downloadURL = metaURL + "/export?" + q.Encode() - } - link := model.Link{ - URL: downloadURL, + URL: buildDownloadURL(fileID, strategy), Header: http.Header{ "Authorization": []string{"Bearer " + d.AccessToken}, }, diff --git a/drivers/google_drive/types.go b/drivers/google_drive/types.go index cbedb9c784..c1ffd74fdc 100644 --- a/drivers/google_drive/types.go +++ b/drivers/google_drive/types.go @@ -33,32 +33,29 @@ const ( mimeTypeScriptJSON = "application/vnd.google-apps.script+json" ) -// exportFormat holds the export target MIME type and the resulting file extension. -type exportFormat struct { - MIME string - Ext string +// googleWorkspaceExports maps exportable Google Workspace source MIME types to the +// corresponding files.export target MIME type. +var googleWorkspaceExports = map[string]string{ + mimeTypeGoogleDoc: mimeTypeDocx, + mimeTypeGoogleSheet: mimeTypeXlsx, + mimeTypeGoogleSlides: mimeTypePptx, + mimeTypeGoogleDrawing: mimeTypePDF, + mimeTypeGoogleScript: mimeTypeScriptJSON, } -// googleWorkspaceExports maps exportable Google Workspace source MIME types to their export targets. -var googleWorkspaceExports = map[string]exportFormat{ - mimeTypeGoogleDoc: {MIME: mimeTypeDocx, Ext: ".docx"}, - mimeTypeGoogleSheet: {MIME: mimeTypeXlsx, Ext: ".xlsx"}, - mimeTypeGoogleSlides: {MIME: mimeTypePptx, Ext: ".pptx"}, - mimeTypeGoogleDrawing: {MIME: mimeTypePDF, Ext: ".pdf"}, - mimeTypeGoogleScript: {MIME: mimeTypeScriptJSON, Ext: ".json"}, -} - -// googleWorkspaceUnsupported lists Google Workspace MIME types that cannot be downloaded -// via files.get or files.export, with a human-readable reason. +// googleWorkspaceUnsupported lists Google Workspace MIME types not handled by this driver, +// with a concise reason describing the implementation limitation. var googleWorkspaceUnsupported = map[string]string{ mimeTypeGoogleFolder: "folders cannot be downloaded", mimeTypeGoogleShortcut: "shortcuts must be resolved before downloading", - mimeTypeGoogleForm: "Google Forms are not downloadable via the Drive API", - mimeTypeGoogleSite: "Google Sites are not downloadable via the Drive API", - mimeTypeGoogleMap: "Google Maps are not downloadable via the Drive API", - // Google Vids require the files.download long-running-operation flow which is not - // yet supported. See https://developers.google.com/drive/api/reference/rest/v3/files/download - mimeTypeGoogleVid: "Google Vids require the Drive files.download long-running-operation flow which is not yet implemented", + // Form, Site, Map and Vid may be reachable via files.download LRO but this driver + // only implements synchronous files.get / files.export. + mimeTypeGoogleForm: "not supported by this driver's synchronous export path; files.download LRO is not implemented", + mimeTypeGoogleSite: "not supported by this driver's synchronous export path; files.download LRO is not implemented", + mimeTypeGoogleMap: "not supported by this driver's synchronous export path; files.download LRO is not implemented", + // Google Vids require the files.download long-running-operation flow. + // See https://developers.google.com/drive/api/reference/rest/v3/files/download + mimeTypeGoogleVid: "requires the files.download long-running-operation flow which is not implemented", } // FileMeta holds the fields we need from a files.get metadata response inside Link(). diff --git a/drivers/google_drive/util.go b/drivers/google_drive/util.go index 96ffdcb810..8e733d551d 100644 --- a/drivers/google_drive/util.go +++ b/drivers/google_drive/util.go @@ -7,9 +7,11 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "regexp" "strconv" + "strings" "time" "github.com/OpenListTeam/OpenList/v4/internal/op" @@ -49,26 +51,54 @@ const ( type downloadStrategy struct { Kind downloadKind ExportMIME string - Ext string } // resolveDownloadStrategy returns the correct download strategy for the given Drive source // MIME type. It returns an error for Google Workspace types that cannot be downloaded. func resolveDownloadStrategy(sourceMIME string) (downloadStrategy, error) { - if ef, ok := googleWorkspaceExports[sourceMIME]; ok { - return downloadStrategy{Kind: kindExport, ExportMIME: ef.MIME, Ext: ef.Ext}, nil + if exportMIME, ok := googleWorkspaceExports[sourceMIME]; ok { + return downloadStrategy{Kind: kindExport, ExportMIME: exportMIME}, nil } if reason, ok := googleWorkspaceUnsupported[sourceMIME]; ok { return downloadStrategy{}, fmt.Errorf("unsupported Google Workspace file type %q: %s", sourceMIME, reason) } - // Any remaining application/vnd.google-apps.* type is not in our allow-list. - if len(sourceMIME) > 28 && sourceMIME[:28] == "application/vnd.google-apps." { + // Any remaining application/vnd.google-apps.* type not in our allow-list. + if strings.HasPrefix(sourceMIME, "application/vnd.google-apps.") { return downloadStrategy{}, fmt.Errorf("unsupported Google Workspace file type: %q", sourceMIME) } // All other MIME types (binary/uploaded files) use the media download endpoint. return downloadStrategy{Kind: kindMedia}, nil } +// buildDownloadURL constructs the correct Drive download URL for the given file ID and strategy. +// +// Binary files use files.get with alt=media: +// +// GET /drive/v3/files/{id}?alt=media&acknowledgeAbuse=true&... +// +// Workspace-native files use files.export with only mimeType: +// +// GET /drive/v3/files/{id}/export?mimeType= +// +// Note: files.export responses are capped by Google Drive at 10 MB. Larger or unsupported +// Workspace downloads may require the files.download long-running-operation (LRO) flow. +func buildDownloadURL(fileID string, strategy downloadStrategy) string { + base := "https://www.googleapis.com/drive/v3/files/" + fileID + switch strategy.Kind { + case kindExport: + q := url.Values{} + q.Set("mimeType", strategy.ExportMIME) + return base + "/export?" + q.Encode() + default: // kindMedia + q := url.Values{} + q.Set("alt", "media") + q.Set("acknowledgeAbuse", "true") + q.Set("includeItemsFromAllDrives", "true") + q.Set("supportsAllDrives", "true") + return base + "?" + q.Encode() + } +} + type googleDriveServiceAccount struct { // Type string `json:"type"` // ProjectID string `json:"project_id"` @@ -204,7 +234,7 @@ func (d *GoogleDrive) refreshToken() error { } log.Debug(res.String()) if e.Error != "" { - return fmt.Errorf("%s", e.Error) + return fmt.Errorf(e.Error) } d.AccessToken = resp.AccessToken return nil @@ -226,7 +256,7 @@ func (d *GoogleDrive) refreshToken() error { } log.Debug(res.String()) if e.Error != "" { - return fmt.Errorf("%s", e.Error) + return fmt.Errorf(e.Error) } d.AccessToken = resp.AccessToken return nil diff --git a/drivers/google_drive/util_test.go b/drivers/google_drive/util_test.go index 0aacc7f5eb..a9ca699549 100644 --- a/drivers/google_drive/util_test.go +++ b/drivers/google_drive/util_test.go @@ -2,7 +2,6 @@ package google_drive import ( "net/url" - "strings" "testing" ) @@ -35,13 +34,12 @@ func TestResolveDownloadStrategy_Export(t *testing.T) { cases := []struct { src string wantMIME string - wantExt string }{ - {mimeTypeGoogleDoc, mimeTypeDocx, ".docx"}, - {mimeTypeGoogleSheet, mimeTypeXlsx, ".xlsx"}, - {mimeTypeGoogleSlides, mimeTypePptx, ".pptx"}, - {mimeTypeGoogleDrawing, mimeTypePDF, ".pdf"}, - {mimeTypeGoogleScript, mimeTypeScriptJSON, ".json"}, + {mimeTypeGoogleDoc, mimeTypeDocx}, + {mimeTypeGoogleSheet, mimeTypeXlsx}, + {mimeTypeGoogleSlides, mimeTypePptx}, + {mimeTypeGoogleDrawing, mimeTypePDF}, + {mimeTypeGoogleScript, mimeTypeScriptJSON}, } for _, c := range cases { t.Run(c.src, func(t *testing.T) { @@ -55,9 +53,6 @@ func TestResolveDownloadStrategy_Export(t *testing.T) { if s.ExportMIME != c.wantMIME { t.Errorf("ExportMIME: got %q, want %q", s.ExportMIME, c.wantMIME) } - if s.Ext != c.wantExt { - t.Errorf("Ext: got %q, want %q", s.Ext, c.wantExt) - } }) } } @@ -82,31 +77,75 @@ func TestResolveDownloadStrategy_Unsupported(t *testing.T) { } } -// TestExportURLEncoding verifies that MIME types with special characters are -// properly percent-encoded in the export URL query string. -func TestExportURLEncoding(t *testing.T) { - cases := []struct { - mime string - want string - }{ - { - mimeTypeDocx, - // / and + must be encoded - url.QueryEscape(mimeTypeDocx), - }, - { - mimeTypeScriptJSON, - url.QueryEscape(mimeTypeScriptJSON), - }, - } - for _, c := range cases { - t.Run(c.mime, func(t *testing.T) { - q := url.Values{} - q.Set("mimeType", c.mime) - encoded := q.Encode() - if !strings.Contains(encoded, c.want) { - t.Errorf("encoded %q does not contain %q", encoded, c.want) +func TestBuildDownloadURL(t *testing.T) { + const fileID = "abc123fileID" + + t.Run("binary/media", func(t *testing.T) { + rawURL := buildDownloadURL(fileID, downloadStrategy{Kind: kindMedia}) + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("url.Parse: %v", err) + } + if u.Scheme != "https" { + t.Errorf("scheme: got %q, want https", u.Scheme) + } + if u.Host != "www.googleapis.com" { + t.Errorf("host: got %q, want www.googleapis.com", u.Host) + } + wantPath := "/drive/v3/files/" + fileID + if u.Path != wantPath { + t.Errorf("path: got %q, want %q", u.Path, wantPath) + } + q := u.Query() + if q.Get("alt") != "media" { + t.Errorf("alt: got %q, want media", q.Get("alt")) + } + if q.Get("acknowledgeAbuse") != "true" { + t.Errorf("acknowledgeAbuse: got %q, want true", q.Get("acknowledgeAbuse")) + } + if q.Get("includeItemsFromAllDrives") != "true" { + t.Errorf("includeItemsFromAllDrives: got %q, want true", q.Get("includeItemsFromAllDrives")) + } + if q.Get("supportsAllDrives") != "true" { + t.Errorf("supportsAllDrives: got %q, want true", q.Get("supportsAllDrives")) + } + }) + + t.Run("Google Doc export", func(t *testing.T) { + rawURL := buildDownloadURL(fileID, downloadStrategy{Kind: kindExport, ExportMIME: mimeTypeDocx}) + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("url.Parse: %v", err) + } + if u.Scheme != "https" { + t.Errorf("scheme: got %q, want https", u.Scheme) + } + wantPath := "/drive/v3/files/" + fileID + "/export" + if u.Path != wantPath { + t.Errorf("path: got %q, want %q", u.Path, wantPath) + } + q := u.Query() + if q.Get("mimeType") != mimeTypeDocx { + t.Errorf("mimeType: got %q, want %q", q.Get("mimeType"), mimeTypeDocx) + } + // Export URL must not carry media-download or shared-drive parameters. + for _, forbidden := range []string{"includeItemsFromAllDrives", "supportsAllDrives", "acknowledgeAbuse", "alt"} { + if q.Has(forbidden) { + t.Errorf("export URL must not contain parameter %q", forbidden) } - }) - } + } + }) + + t.Run("Apps Script export MIME encoding", func(t *testing.T) { + // mimeTypeScriptJSON contains '+' which must survive round-trip encoding. + rawURL := buildDownloadURL(fileID, downloadStrategy{Kind: kindExport, ExportMIME: mimeTypeScriptJSON}) + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("url.Parse: %v", err) + } + q := u.Query() + if q.Get("mimeType") != mimeTypeScriptJSON { + t.Errorf("mimeType: got %q, want %q", q.Get("mimeType"), mimeTypeScriptJSON) + } + }) } From 57660d03010885112a7b7455e816d23975c63d80 Mon Sep 17 00:00:00 2001 From: Mythic3011 <42365807+mythic3011@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:40:51 +0800 Subject: [PATCH 3/6] fix(google_drive): tighten download strategy compatibility - Remove includeItemsFromAllDrives from files.get media URL; it is a files.list parameter, not a files.get parameter; supportsAllDrives is preserved for shared-drive support - Remove blanket application/vnd.google-apps.* prefix rejection; unrecognised Drive MIME types now fall through to kindMedia to preserve compatibility with non-editor types such as application/vnd.google-apps.video / audio / photo - Remove now-unused strings import - Separate Google My Maps from the files.download LRO group; its unsupported reason is now implementation-neutral - Add TestResolveDownloadStrategy_MediaFallback covering video, audio, photo and unknown google-apps variants - Update TestBuildDownloadURL: assert includeItemsFromAllDrives is absent from media URL; remove stale assertion --- drivers/google_drive/types.go | 7 ++++--- drivers/google_drive/util.go | 10 +++------ drivers/google_drive/util_test.go | 34 +++++++++++++++++++++++++++---- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/drivers/google_drive/types.go b/drivers/google_drive/types.go index c1ffd74fdc..d22e17a41f 100644 --- a/drivers/google_drive/types.go +++ b/drivers/google_drive/types.go @@ -48,11 +48,12 @@ var googleWorkspaceExports = map[string]string{ var googleWorkspaceUnsupported = map[string]string{ mimeTypeGoogleFolder: "folders cannot be downloaded", mimeTypeGoogleShortcut: "shortcuts must be resolved before downloading", - // Form, Site, Map and Vid may be reachable via files.download LRO but this driver - // only implements synchronous files.get / files.export. + // Forms and Sites are documented as downloadable via the files.download LRO flow, + // which this driver does not implement. mimeTypeGoogleForm: "not supported by this driver's synchronous export path; files.download LRO is not implemented", mimeTypeGoogleSite: "not supported by this driver's synchronous export path; files.download LRO is not implemented", - mimeTypeGoogleMap: "not supported by this driver's synchronous export path; files.download LRO is not implemented", + // Google My Maps has no documented synchronous download/export path. + mimeTypeGoogleMap: "no supported download/export strategy is implemented for Google My Maps", // Google Vids require the files.download long-running-operation flow. // See https://developers.google.com/drive/api/reference/rest/v3/files/download mimeTypeGoogleVid: "requires the files.download long-running-operation flow which is not implemented", diff --git a/drivers/google_drive/util.go b/drivers/google_drive/util.go index 8e733d551d..5fc39abc33 100644 --- a/drivers/google_drive/util.go +++ b/drivers/google_drive/util.go @@ -11,7 +11,6 @@ import ( "os" "regexp" "strconv" - "strings" "time" "github.com/OpenListTeam/OpenList/v4/internal/op" @@ -62,11 +61,9 @@ func resolveDownloadStrategy(sourceMIME string) (downloadStrategy, error) { if reason, ok := googleWorkspaceUnsupported[sourceMIME]; ok { return downloadStrategy{}, fmt.Errorf("unsupported Google Workspace file type %q: %s", sourceMIME, reason) } - // Any remaining application/vnd.google-apps.* type not in our allow-list. - if strings.HasPrefix(sourceMIME, "application/vnd.google-apps.") { - return downloadStrategy{}, fmt.Errorf("unsupported Google Workspace file type: %q", sourceMIME) - } - // All other MIME types (binary/uploaded files) use the media download endpoint. + // All other MIME types — including unrecognised application/vnd.google-apps.* variants + // and all binary/uploaded files — fall through to the media download endpoint to preserve + // compatibility with Drive MIME types not covered by the maps above. return downloadStrategy{Kind: kindMedia}, nil } @@ -93,7 +90,6 @@ func buildDownloadURL(fileID string, strategy downloadStrategy) string { q := url.Values{} q.Set("alt", "media") q.Set("acknowledgeAbuse", "true") - q.Set("includeItemsFromAllDrives", "true") q.Set("supportsAllDrives", "true") return base + "?" + q.Encode() } diff --git a/drivers/google_drive/util_test.go b/drivers/google_drive/util_test.go index a9ca699549..6cd32866c0 100644 --- a/drivers/google_drive/util_test.go +++ b/drivers/google_drive/util_test.go @@ -65,7 +65,6 @@ func TestResolveDownloadStrategy_Unsupported(t *testing.T) { mimeTypeGoogleSite, mimeTypeGoogleMap, mimeTypeGoogleVid, - "application/vnd.google-apps.unknown-future-type", } for _, mime := range cases { t.Run(mime, func(t *testing.T) { @@ -77,6 +76,32 @@ func TestResolveDownloadStrategy_Unsupported(t *testing.T) { } } +// TestResolveDownloadStrategy_MediaFallback verifies that unrecognised +// application/vnd.google-apps.* MIME types fall through to the media download +// path rather than being rejected. This preserves compatibility with Drive MIME +// types that are not Workspace-native documents. +func TestResolveDownloadStrategy_MediaFallback(t *testing.T) { + cases := []string{ + // Documented Drive-specific types that are not Workspace editors. + "application/vnd.google-apps.video", + "application/vnd.google-apps.audio", + "application/vnd.google-apps.photo", + // Unknown type: should not be blocked by a blanket prefix rejection. + "application/vnd.google-apps.unknown-future-type", + } + for _, mime := range cases { + t.Run(mime, func(t *testing.T) { + s, err := resolveDownloadStrategy(mime) + if err != nil { + t.Fatalf("unexpected error for %q: %v", mime, err) + } + if s.Kind != kindMedia { + t.Errorf("expected kindMedia fallback for %q, got %v", mime, s.Kind) + } + }) + } +} + func TestBuildDownloadURL(t *testing.T) { const fileID = "abc123fileID" @@ -103,12 +128,13 @@ func TestBuildDownloadURL(t *testing.T) { if q.Get("acknowledgeAbuse") != "true" { t.Errorf("acknowledgeAbuse: got %q, want true", q.Get("acknowledgeAbuse")) } - if q.Get("includeItemsFromAllDrives") != "true" { - t.Errorf("includeItemsFromAllDrives: got %q, want true", q.Get("includeItemsFromAllDrives")) - } if q.Get("supportsAllDrives") != "true" { t.Errorf("supportsAllDrives: got %q, want true", q.Get("supportsAllDrives")) } + // includeItemsFromAllDrives is a files.list parameter, not files.get. + if q.Has("includeItemsFromAllDrives") { + t.Errorf("media URL must not contain includeItemsFromAllDrives") + } }) t.Run("Google Doc export", func(t *testing.T) { From 88e29d75058a185919914969fd21de27542a6108 Mon Sep 17 00:00:00 2001 From: Mythic3011 <42365807+mythic3011@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:40:51 +0800 Subject: [PATCH 4/6] fix(google_drive): add Jamboard to unsupported types application/vnd.google-apps.jam falls through to the media download path without this change, reproducing the fileNotDownloadable 403. Jamboard downloads require the files.download LRO flow, consistent with Vids. --- drivers/google_drive/types.go | 4 +++- drivers/google_drive/util_test.go | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/drivers/google_drive/types.go b/drivers/google_drive/types.go index d22e17a41f..ea85001ab3 100644 --- a/drivers/google_drive/types.go +++ b/drivers/google_drive/types.go @@ -22,6 +22,7 @@ const ( mimeTypeGoogleSite = "application/vnd.google-apps.site" mimeTypeGoogleMap = "application/vnd.google-apps.map" mimeTypeGoogleVid = "application/vnd.google-apps.vid" + mimeTypeGoogleJam = "application/vnd.google-apps.jam" ) // Export target MIME types. @@ -54,9 +55,10 @@ var googleWorkspaceUnsupported = map[string]string{ mimeTypeGoogleSite: "not supported by this driver's synchronous export path; files.download LRO is not implemented", // Google My Maps has no documented synchronous download/export path. mimeTypeGoogleMap: "no supported download/export strategy is implemented for Google My Maps", - // Google Vids require the files.download long-running-operation flow. + // Google Vids and Jamboard require the files.download long-running-operation flow. // See https://developers.google.com/drive/api/reference/rest/v3/files/download mimeTypeGoogleVid: "requires the files.download long-running-operation flow which is not implemented", + mimeTypeGoogleJam: "requires the files.download long-running-operation flow which is not implemented", } // FileMeta holds the fields we need from a files.get metadata response inside Link(). diff --git a/drivers/google_drive/util_test.go b/drivers/google_drive/util_test.go index 6cd32866c0..0ff455a0b9 100644 --- a/drivers/google_drive/util_test.go +++ b/drivers/google_drive/util_test.go @@ -65,6 +65,7 @@ func TestResolveDownloadStrategy_Unsupported(t *testing.T) { mimeTypeGoogleSite, mimeTypeGoogleMap, mimeTypeGoogleVid, + mimeTypeGoogleJam, } for _, mime := range cases { t.Run(mime, func(t *testing.T) { From d96708e3e0f48fe3fe48287ba672bc7ca55961d9 Mon Sep 17 00:00:00 2001 From: Mythic3011 <42365807+mythic3011@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:40:51 +0800 Subject: [PATCH 5/6] feat(google_drive): set exported filename with correct extension - Add FileName field to model.Link for driver-supplied download filename override - Preserve FileName in Link.Clone() - Add linkFileName helper in proxy.go; use it in all Content-Disposition and ServeHTTP calls - Restore exportFormat struct with MIME and Extension fields in types.go - Change googleWorkspaceExports to map[string]exportFormat with per-type extensions - Add mimeTypeGoogleDriveSDK constant and unsupported entry - Add Extension field to downloadStrategy; populate from exportFormat in resolveDownloadStrategy - Add exportedFileName helper (case-insensitive suffix check to avoid double extensions) - Set link.FileName in Link() for kindExport strategies - Update TestResolveDownloadStrategy_Export to check Extension field - Add mimeTypeGoogleDriveSDK to TestResolveDownloadStrategy_Unsupported - Add TestExportedFileName covering CJK names, already-extended names, case-insensitive dedup - Add TestLinkFileName and TestProxyUsesLinkFileNameForContentDisposition --- drivers/google_drive/driver.go | 3 +++ drivers/google_drive/types.go | 30 ++++++++++++++-------- drivers/google_drive/util.go | 17 +++++++++++-- drivers/google_drive/util_test.go | 37 +++++++++++++++++++++++---- internal/model/args.go | 5 ++++ server/common/proxy.go | 17 ++++++++++--- server/common/proxy_test.go | 42 +++++++++++++++++++++++++++++++ 7 files changed, 130 insertions(+), 21 deletions(-) diff --git a/drivers/google_drive/driver.go b/drivers/google_drive/driver.go index 67c86efc5a..ce3cb7df46 100644 --- a/drivers/google_drive/driver.go +++ b/drivers/google_drive/driver.go @@ -78,6 +78,9 @@ func (d *GoogleDrive) Link(ctx context.Context, file model.Obj, args model.LinkA "Authorization": []string{"Bearer " + d.AccessToken}, }, } + if strategy.Kind == kindExport { + link.FileName = exportedFileName(file.GetName(), strategy.Extension) + } return &link, nil } diff --git a/drivers/google_drive/types.go b/drivers/google_drive/types.go index ea85001ab3..5585dfe0c6 100644 --- a/drivers/google_drive/types.go +++ b/drivers/google_drive/types.go @@ -23,6 +23,8 @@ const ( mimeTypeGoogleMap = "application/vnd.google-apps.map" mimeTypeGoogleVid = "application/vnd.google-apps.vid" mimeTypeGoogleJam = "application/vnd.google-apps.jam" + // Third-party shortcut created by a Drive app; the app must handle the download. + mimeTypeGoogleDriveSDK = "application/vnd.google-apps.drive-sdk" ) // Export target MIME types. @@ -34,14 +36,21 @@ const ( mimeTypeScriptJSON = "application/vnd.google-apps.script+json" ) -// googleWorkspaceExports maps exportable Google Workspace source MIME types to the -// corresponding files.export target MIME type. -var googleWorkspaceExports = map[string]string{ - mimeTypeGoogleDoc: mimeTypeDocx, - mimeTypeGoogleSheet: mimeTypeXlsx, - mimeTypeGoogleSlides: mimeTypePptx, - mimeTypeGoogleDrawing: mimeTypePDF, - mimeTypeGoogleScript: mimeTypeScriptJSON, +// exportFormat pairs a files.export target MIME type with the file extension +// that should be appended to the downloaded filename. +type exportFormat struct { + MIME string + Extension string +} + +// googleWorkspaceExports maps exportable Google Workspace source MIME types to their +// files.export target format. +var googleWorkspaceExports = map[string]exportFormat{ + mimeTypeGoogleDoc: {MIME: mimeTypeDocx, Extension: ".docx"}, + mimeTypeGoogleSheet: {MIME: mimeTypeXlsx, Extension: ".xlsx"}, + mimeTypeGoogleSlides: {MIME: mimeTypePptx, Extension: ".pptx"}, + mimeTypeGoogleDrawing: {MIME: mimeTypePDF, Extension: ".pdf"}, + mimeTypeGoogleScript: {MIME: mimeTypeScriptJSON, Extension: ".json"}, } // googleWorkspaceUnsupported lists Google Workspace MIME types not handled by this driver, @@ -57,8 +66,9 @@ var googleWorkspaceUnsupported = map[string]string{ mimeTypeGoogleMap: "no supported download/export strategy is implemented for Google My Maps", // Google Vids and Jamboard require the files.download long-running-operation flow. // See https://developers.google.com/drive/api/reference/rest/v3/files/download - mimeTypeGoogleVid: "requires the files.download long-running-operation flow which is not implemented", - mimeTypeGoogleJam: "requires the files.download long-running-operation flow which is not implemented", + mimeTypeGoogleVid: "requires the files.download long-running-operation flow which is not implemented", + mimeTypeGoogleJam: "requires the files.download long-running-operation flow which is not implemented", + mimeTypeGoogleDriveSDK: "third-party shortcut (drive-sdk) cannot be downloaded directly", } // FileMeta holds the fields we need from a files.get metadata response inside Link(). diff --git a/drivers/google_drive/util.go b/drivers/google_drive/util.go index 5fc39abc33..38b3298578 100644 --- a/drivers/google_drive/util.go +++ b/drivers/google_drive/util.go @@ -11,6 +11,7 @@ import ( "os" "regexp" "strconv" + "strings" "time" "github.com/OpenListTeam/OpenList/v4/internal/op" @@ -50,13 +51,16 @@ const ( type downloadStrategy struct { Kind downloadKind ExportMIME string + // Extension is the file extension (e.g. ".pptx") to append to the downloaded filename + // for kindExport strategies. Empty for kindMedia. + Extension string } // resolveDownloadStrategy returns the correct download strategy for the given Drive source // MIME type. It returns an error for Google Workspace types that cannot be downloaded. func resolveDownloadStrategy(sourceMIME string) (downloadStrategy, error) { - if exportMIME, ok := googleWorkspaceExports[sourceMIME]; ok { - return downloadStrategy{Kind: kindExport, ExportMIME: exportMIME}, nil + if ef, ok := googleWorkspaceExports[sourceMIME]; ok { + return downloadStrategy{Kind: kindExport, ExportMIME: ef.MIME, Extension: ef.Extension}, nil } if reason, ok := googleWorkspaceUnsupported[sourceMIME]; ok { return downloadStrategy{}, fmt.Errorf("unsupported Google Workspace file type %q: %s", sourceMIME, reason) @@ -95,6 +99,15 @@ func buildDownloadURL(fileID string, strategy downloadStrategy) string { } } +// exportedFileName returns name with ext appended, unless name already ends with +// ext (case-insensitive), which avoids doubling extensions like "file.pptx.pptx". +func exportedFileName(name, ext string) string { + if strings.HasSuffix(strings.ToLower(name), strings.ToLower(ext)) { + return name + } + return name + ext +} + type googleDriveServiceAccount struct { // Type string `json:"type"` // ProjectID string `json:"project_id"` diff --git a/drivers/google_drive/util_test.go b/drivers/google_drive/util_test.go index 0ff455a0b9..c94a43b533 100644 --- a/drivers/google_drive/util_test.go +++ b/drivers/google_drive/util_test.go @@ -34,12 +34,13 @@ func TestResolveDownloadStrategy_Export(t *testing.T) { cases := []struct { src string wantMIME string + wantExt string }{ - {mimeTypeGoogleDoc, mimeTypeDocx}, - {mimeTypeGoogleSheet, mimeTypeXlsx}, - {mimeTypeGoogleSlides, mimeTypePptx}, - {mimeTypeGoogleDrawing, mimeTypePDF}, - {mimeTypeGoogleScript, mimeTypeScriptJSON}, + {mimeTypeGoogleDoc, mimeTypeDocx, ".docx"}, + {mimeTypeGoogleSheet, mimeTypeXlsx, ".xlsx"}, + {mimeTypeGoogleSlides, mimeTypePptx, ".pptx"}, + {mimeTypeGoogleDrawing, mimeTypePDF, ".pdf"}, + {mimeTypeGoogleScript, mimeTypeScriptJSON, ".json"}, } for _, c := range cases { t.Run(c.src, func(t *testing.T) { @@ -53,6 +54,9 @@ func TestResolveDownloadStrategy_Export(t *testing.T) { if s.ExportMIME != c.wantMIME { t.Errorf("ExportMIME: got %q, want %q", s.ExportMIME, c.wantMIME) } + if s.Extension != c.wantExt { + t.Errorf("Extension: got %q, want %q", s.Extension, c.wantExt) + } }) } } @@ -66,6 +70,7 @@ func TestResolveDownloadStrategy_Unsupported(t *testing.T) { mimeTypeGoogleMap, mimeTypeGoogleVid, mimeTypeGoogleJam, + mimeTypeGoogleDriveSDK, } for _, mime := range cases { t.Run(mime, func(t *testing.T) { @@ -103,6 +108,28 @@ func TestResolveDownloadStrategy_MediaFallback(t *testing.T) { } } +func TestExportedFileName(t *testing.T) { + cases := []struct { + name string + ext string + want string + }{ + {"未命名簡報", ".pptx", "未命名簡報.pptx"}, + {"report", ".docx", "report.docx"}, + {"already.PPTX", ".pptx", "already.PPTX"}, + {"drawing.pdf", ".pdf", "drawing.pdf"}, + {"script", ".json", "script.json"}, + } + for _, c := range cases { + t.Run(c.name+c.ext, func(t *testing.T) { + got := exportedFileName(c.name, c.ext) + if got != c.want { + t.Errorf("exportedFileName(%q, %q) = %q, want %q", c.name, c.ext, got, c.want) + } + }) + } +} + func TestBuildDownloadURL(t *testing.T) { const fileID = "abc123fileID" diff --git a/internal/model/args.go b/internal/model/args.go index 16a5c1722f..956ee4d438 100644 --- a/internal/model/args.go +++ b/internal/model/args.go @@ -40,6 +40,10 @@ type Link struct { utils.SyncClosers `json:"-"` // 如果SyncClosers中的资源被关闭后Link将不可用,则此值应为 true RequireReference bool `json:"-"` + + // FileName overrides the download filename in Content-Disposition when non-empty. + // Drivers use this to supply the correct extension for exported files. + FileName string `json:"-"` } // Clone transfers ownership of l without inheriting its cache expiration. @@ -53,6 +57,7 @@ func (l *Link) Clone() *Link { ContentLength: l.ContentLength, SyncClosers: utils.NewSyncClosers(l), RequireReference: l.RequireReference, + FileName: l.FileName, } } diff --git a/server/common/proxy.go b/server/common/proxy.go index 3522fe9972..ed763554e3 100644 --- a/server/common/proxy.go +++ b/server/common/proxy.go @@ -34,7 +34,7 @@ func Proxy(w http.ResponseWriter, r *http.Request, link *model.Link, file model. if link.RangeReader == nil { r = r.WithContext(context.WithValue(r.Context(), conf.RequestHeaderKey, r.Header)) } - return net.ServeHTTP(w, r, file.GetName(), file.ModTime(), size, rrf) + return net.ServeHTTP(w, r, linkFileName(file, link), file.ModTime(), size, rrf) } if link.RangeReader != nil { @@ -43,7 +43,7 @@ func Proxy(w http.ResponseWriter, r *http.Request, link *model.Link, file model. if size <= 0 { size = file.GetSize() } - return net.ServeHTTP(w, r, file.GetName(), file.ModTime(), size, link.RangeReader) + return net.ServeHTTP(w, r, linkFileName(file, link), file.ModTime(), size, link.RangeReader) } //transparent proxy @@ -55,7 +55,7 @@ func Proxy(w http.ResponseWriter, r *http.Request, link *model.Link, file model. defer res.Body.Close() maps.Copy(w.Header(), res.Header) - w.Header().Set("Content-Disposition", utils.GenerateContentDisposition(file.GetName())) + w.Header().Set("Content-Disposition", utils.GenerateContentDisposition(linkFileName(file, link))) w.WriteHeader(res.StatusCode) if r.Method == http.MethodHead { return nil @@ -67,8 +67,17 @@ func Proxy(w http.ResponseWriter, r *http.Request, link *model.Link, file model. }) return err } + +// linkFileName returns link.FileName when set, otherwise file.GetName(). +func linkFileName(file model.Obj, link *model.Link) string { + if link.FileName != "" { + return link.FileName + } + return file.GetName() +} + func attachHeader(w http.ResponseWriter, file model.Obj, link *model.Link) { - fileName := file.GetName() + fileName := linkFileName(file, link) w.Header().Set("Content-Disposition", utils.GenerateContentDisposition(fileName)) w.Header().Set("Content-Type", utils.GetMimeType(fileName)) size := link.ContentLength diff --git a/server/common/proxy_test.go b/server/common/proxy_test.go index 314763b2f5..03b5566a24 100644 --- a/server/common/proxy_test.go +++ b/server/common/proxy_test.go @@ -11,6 +11,48 @@ import ( "github.com/OpenListTeam/OpenList/v4/pkg/utils" ) +func TestLinkFileName(t *testing.T) { + file := &model.Object{Name: "original.name"} + linkWithName := &model.Link{FileName: "override.pptx"} + linkEmpty := &model.Link{} + + if got := linkFileName(file, linkWithName); got != "override.pptx" { + t.Errorf("linkFileName with FileName set: got %q, want override.pptx", got) + } + if got := linkFileName(file, linkEmpty); got != "original.name" { + t.Errorf("linkFileName with empty FileName: got %q, want original.name", got) + } +} + +func TestProxyUsesLinkFileNameForContentDisposition(t *testing.T) { + previousConfig := conf.Conf + conf.Conf = conf.DefaultConfig("data") + t.Cleanup(func() { + conf.Conf = previousConfig + }) + + const content = "slide content" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, content) + })) + t.Cleanup(upstream.Close) + + file := &model.Object{Name: "未命名簡報", Size: int64(len(content))} + link := &model.Link{URL: upstream.URL, FileName: "未命名簡報.pptx"} + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/sd/example", nil) + + err := Proxy(recorder, request, link, file) + if err != nil { + t.Fatalf("Proxy() error = %v", err) + } + want := utils.GenerateContentDisposition("未命名簡報.pptx") + if got := recorder.Header().Get("Content-Disposition"); got != want { + t.Errorf("Content-Disposition = %q, want %q", got, want) + } +} + func TestProxyOverridesUpstreamContentDisposition(t *testing.T) { previousConfig := conf.Conf conf.Conf = conf.DefaultConfig("data") From 3cafe34343010d448ad7f8bcc553b42df9b3b247 Mon Sep 17 00:00:00 2001 From: Mythic3011 <42365807+mythic3011@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:49:09 +0800 Subject: [PATCH 6/6] fix(google_drive): clarify unsupported Workspace MIME reasons Replace googleWorkspaceUnsupported messages with consistent wording for LRO-only types, drive-sdk shortcuts, and My Maps. --- drivers/google_drive/types.go | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/drivers/google_drive/types.go b/drivers/google_drive/types.go index 5585dfe0c6..eff535adde 100644 --- a/drivers/google_drive/types.go +++ b/drivers/google_drive/types.go @@ -53,22 +53,20 @@ var googleWorkspaceExports = map[string]exportFormat{ mimeTypeGoogleScript: {MIME: mimeTypeScriptJSON, Extension: ".json"}, } -// googleWorkspaceUnsupported lists Google Workspace MIME types not handled by this driver, -// with a concise reason describing the implementation limitation. var googleWorkspaceUnsupported = map[string]string{ mimeTypeGoogleFolder: "folders cannot be downloaded", mimeTypeGoogleShortcut: "shortcuts must be resolved before downloading", - // Forms and Sites are documented as downloadable via the files.download LRO flow, - // which this driver does not implement. - mimeTypeGoogleForm: "not supported by this driver's synchronous export path; files.download LRO is not implemented", - mimeTypeGoogleSite: "not supported by this driver's synchronous export path; files.download LRO is not implemented", - // Google My Maps has no documented synchronous download/export path. + mimeTypeGoogleDriveSDK: "third-party shortcuts cannot be downloaded directly", + + // Forms, Sites, Vids, and Jamboard require the files.download + // long-running-operation flow, which this driver does not implement. + mimeTypeGoogleForm: "requires the files.download long-running-operation flow, which this driver does not implement", + mimeTypeGoogleSite: "requires the files.download long-running-operation flow, which this driver does not implement", + mimeTypeGoogleVid: "requires the files.download long-running-operation flow, which this driver does not implement", + mimeTypeGoogleJam: "requires the files.download long-running-operation flow, which this driver does not implement", + + // Google My Maps has no supported download/export strategy implemented here. mimeTypeGoogleMap: "no supported download/export strategy is implemented for Google My Maps", - // Google Vids and Jamboard require the files.download long-running-operation flow. - // See https://developers.google.com/drive/api/reference/rest/v3/files/download - mimeTypeGoogleVid: "requires the files.download long-running-operation flow which is not implemented", - mimeTypeGoogleJam: "requires the files.download long-running-operation flow which is not implemented", - mimeTypeGoogleDriveSDK: "third-party shortcut (drive-sdk) cannot be downloaded directly", } // FileMeta holds the fields we need from a files.get metadata response inside Link().