diff --git a/drivers/google_drive/driver.go b/drivers/google_drive/driver.go index 94ef854f2f..ce3cb7df46 100644 --- a/drivers/google_drive/driver.go +++ b/drivers/google_drive/driver.go @@ -52,17 +52,35 @@ 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 + } + link := model.Link{ - URL: url + "&alt=media&acknowledgeAbuse=true", + URL: buildDownloadURL(fileID, strategy), Header: http.Header{ "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 5e71f305ff..eff535adde 100644 --- a/drivers/google_drive/types.go +++ b/drivers/google_drive/types.go @@ -9,6 +9,74 @@ 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" + 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. +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 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"}, +} + +var googleWorkspaceUnsupported = map[string]string{ + mimeTypeGoogleFolder: "folders cannot be downloaded", + mimeTypeGoogleShortcut: "shortcuts must be resolved before downloading", + 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", +} + +// 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..38b3298578 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" @@ -31,10 +33,81 @@ 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 + // 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 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) + } + // 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 +} + +// 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("supportsAllDrives", "true") + return base + "?" + q.Encode() + } +} + +// 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 new file mode 100644 index 0000000000..c94a43b533 --- /dev/null +++ b/drivers/google_drive/util_test.go @@ -0,0 +1,205 @@ +package google_drive + +import ( + "net/url" + "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.Extension != c.wantExt { + t.Errorf("Extension: got %q, want %q", s.Extension, c.wantExt) + } + }) + } +} + +func TestResolveDownloadStrategy_Unsupported(t *testing.T) { + cases := []string{ + mimeTypeGoogleFolder, + mimeTypeGoogleShortcut, + mimeTypeGoogleForm, + mimeTypeGoogleSite, + mimeTypeGoogleMap, + mimeTypeGoogleVid, + mimeTypeGoogleJam, + mimeTypeGoogleDriveSDK, + } + 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) + } + }) + } +} + +// 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 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" + + 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("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) { + 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) + } + }) +} 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")