Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions drivers/google_drive/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
68 changes: 68 additions & 0 deletions drivers/google_drive/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
75 changes: 74 additions & 1 deletion drivers/google_drive/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"

"github.com/OpenListTeam/OpenList/v4/internal/op"
Expand All @@ -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=<encoded-mime>
//
// 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"`
Expand Down
Loading