feat(drive): implement external URL resolution for mounted cloud files
- Added new functionality to resolve external URLs for files on Google Drive and Microsoft OneDrive mounts. - Introduced `mount_cloud_service.go` to handle OAuth token extraction and URL resolution. - Enhanced `mounts_service.go` to update mount configurations with OAuth tokens. - Updated API routes to include a new endpoint for fetching external URLs. - Implemented enrichment functions in `cloud_native.go` to mark files that should open in the provider's web editor. - Added tests for cloud-native file enrichment in `cloud_native_test.go` to ensure correct behavior.
This commit is contained in:
parent
951c88b1ca
commit
857b9afc43
98
internal/api/drive/mount_cloud_service.go
Normal file
98
internal/api/drive/mount_cloud_service.go
Normal file
@ -0,0 +1,98 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/driveroot"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
)
|
||||
|
||||
func mountOAuthAccessToken(configEnc []byte) string {
|
||||
if len(configEnc) == 0 {
|
||||
return ""
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(configEnc, &cfg); err != nil {
|
||||
return ""
|
||||
}
|
||||
if raw, ok := cfg["token"].(string); ok {
|
||||
return nextcloud.ParseOAuthAccessToken(raw)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ResolveMountExternalURL returns the provider web URL for a file on an external mount.
|
||||
func (s *Service) ResolveMountExternalURL(ctx context.Context, ncUserID, mountID, platformUserID, logicalPath string) (string, error) {
|
||||
store := s.ensureStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("store not configured")
|
||||
}
|
||||
mount, err := store.GetMount(ctx, mountID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if mount.OwnerUserID == nil || *mount.OwnerUserID != platformUserID {
|
||||
return "", ErrForbidden
|
||||
}
|
||||
logicalPath = nextcloud.NormalizeClientPath(logicalPath)
|
||||
if logicalPath == "/" {
|
||||
return "", ErrInvalid
|
||||
}
|
||||
|
||||
file, err := s.StatFileAtRoot(ctx, ncUserID, driveroot.Mount(mountID, logicalPath))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if file.ExternalURL != "" {
|
||||
return file.ExternalURL, nil
|
||||
}
|
||||
if !file.OpenExternally {
|
||||
return "", ErrInvalid
|
||||
}
|
||||
|
||||
configEnc, err := store.GetMountConfig(ctx, mountID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
accessToken := mountOAuthAccessToken(configEnc)
|
||||
if accessToken == "" {
|
||||
return "", fmt.Errorf("mount oauth token not available")
|
||||
}
|
||||
|
||||
resolver := &nextcloud.MountCloudResolver{}
|
||||
backend := driveroot.NormalizeMountBackend(mount.BackendType)
|
||||
switch backend {
|
||||
case "google":
|
||||
if id := providerIDFromETag(file.ETag); id != "" {
|
||||
link, err := resolver.GoogleDriveWebViewLink(ctx, accessToken, id)
|
||||
if err == nil && link != "" {
|
||||
return link, nil
|
||||
}
|
||||
}
|
||||
return resolver.ResolveGoogleDrivePath(ctx, accessToken, logicalPath)
|
||||
case "microsoft":
|
||||
return resolver.ResolveMicrosoftDrivePath(ctx, accessToken, logicalPath)
|
||||
default:
|
||||
return "", ErrInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func providerIDFromETag(etag string) string {
|
||||
id := strings.Trim(strings.TrimSpace(etag), "\"")
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range id {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if len(id) < 10 {
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
@ -294,5 +294,17 @@ func (s *Service) CompleteMountOAuth(ctx context.Context, mountID, platformUserI
|
||||
_ = store.UpdateMountStatus(ctx, mount.ID, "error", err.Error(), mount.NCMountID)
|
||||
return err
|
||||
}
|
||||
configEnc, _ := store.GetMountConfig(ctx, mount.ID)
|
||||
cfg := map[string]any{}
|
||||
if len(configEnc) > 0 {
|
||||
_ = json.Unmarshal(configEnc, &cfg)
|
||||
}
|
||||
cfg["token"] = token
|
||||
cfg["configured"] = true
|
||||
cfg["client_id"] = creds.ClientID
|
||||
cfg["client_secret"] = creds.ClientSecret
|
||||
if merged, err := json.Marshal(cfg); err == nil {
|
||||
_ = store.UpdateMountConfig(ctx, mount.ID, merged)
|
||||
}
|
||||
return store.UpdateMountStatus(ctx, mount.ID, "active", "", mount.NCMountID)
|
||||
}
|
||||
|
||||
@ -33,6 +33,7 @@ func (h *Handler) registerOrgAndMountRoutes(r chi.Router, read, write func(http.
|
||||
r.With(write).Post("/mounts/{mountID}/oauth/complete", h.CompleteMountOAuth)
|
||||
r.With(read).Get("/mounts/{mountID}/files/*", h.ListMountFiles)
|
||||
r.With(read).Get("/mounts/{mountID}/files/info/*", h.GetMountFileInfo)
|
||||
r.With(read).Get("/mounts/{mountID}/files/external-url/*", h.GetMountFileExternalURL)
|
||||
r.With(read).Get("/mounts/{mountID}/download/*", h.DownloadMountFile)
|
||||
r.With(read).Get("/mounts/{mountID}/preview/*", h.PreviewMountFile)
|
||||
r.With(write).Post("/mounts/{mountID}/files/*", h.UploadMountFile)
|
||||
@ -93,6 +94,27 @@ func (h *Handler) GetMountFileInfo(w http.ResponseWriter, r *http.Request) {
|
||||
h.getRootFileInfo(w, r, driveroot.KindMount, chi.URLParam(r, "mountID"))
|
||||
}
|
||||
|
||||
func (h *Handler) GetMountFileExternalURL(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
platformUserID, err := h.svc.platformUserID(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
writeDriveError(w, r, err)
|
||||
return
|
||||
}
|
||||
mountID := chi.URLParam(r, "mountID")
|
||||
path := nextcloud.NormalizeClientPath(chi.URLParam(r, "*"))
|
||||
externalURL, err := h.svc.ResolveMountExternalURL(r.Context(), ncUser, mountID, platformUserID, path)
|
||||
if err != nil {
|
||||
writeDriveError(w, r, err)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"external_url": externalURL})
|
||||
}
|
||||
|
||||
func (h *Handler) getRootFileInfo(w http.ResponseWriter, r *http.Request, kind driveroot.Kind, rootID string) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
|
||||
@ -156,6 +156,12 @@ func (s *Service) ListFilesAtRoot(ctx context.Context, ncUserID string, ref driv
|
||||
return FilesList{}, mapDriveError(err)
|
||||
}
|
||||
files = driveroot.EnrichFiles(files, resolved.ref)
|
||||
if ref.Kind == driveroot.KindMount {
|
||||
mount, mountErr := s.ensureStore().GetMount(ctx, ref.RootID)
|
||||
if mountErr == nil {
|
||||
files = driveroot.EnrichMountCloudNativeFiles(files, mount.BackendType)
|
||||
}
|
||||
}
|
||||
filtered := visibleDriveFiles(files, params.Q)
|
||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
return FilesList{
|
||||
@ -178,7 +184,14 @@ func (s *Service) StatFileAtRoot(ctx context.Context, ncUserID string, ref drive
|
||||
if err != nil {
|
||||
return nextcloud.FileInfo{}, mapDriveError(err)
|
||||
}
|
||||
return driveroot.EnrichFile(file, ref), nil
|
||||
file = driveroot.EnrichFile(file, ref)
|
||||
if ref.Kind == driveroot.KindMount {
|
||||
mount, mountErr := s.ensureStore().GetMount(ctx, ref.RootID)
|
||||
if mountErr == nil {
|
||||
driveroot.EnrichMountCloudNative(&file, mount.BackendType)
|
||||
}
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (s *Service) UploadAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref, content io.Reader, contentType string, contentLength int64) error {
|
||||
|
||||
128
internal/driveroot/cloud_native.go
Normal file
128
internal/driveroot/cloud_native.go
Normal file
@ -0,0 +1,128 @@
|
||||
package driveroot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
)
|
||||
|
||||
// NormalizeMountBackend maps stored backend_type to a provider family.
|
||||
func NormalizeMountBackend(backendType string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(backendType)) {
|
||||
case "googledrive", "google":
|
||||
return "google"
|
||||
case "onedrive", "microsoft":
|
||||
return "microsoft"
|
||||
default:
|
||||
return strings.TrimSpace(strings.ToLower(backendType))
|
||||
}
|
||||
}
|
||||
|
||||
func isGoogleWorkspaceNativeMime(mimeType string) bool {
|
||||
switch strings.TrimSpace(mimeType) {
|
||||
case "application/vnd.google-apps.document",
|
||||
"application/vnd.google-apps.spreadsheet",
|
||||
"application/vnd.google-apps.presentation":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isMicrosoftOfficeMime(mimeType, name string) bool {
|
||||
mime := strings.ToLower(strings.TrimSpace(mimeType))
|
||||
switch mime {
|
||||
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.ms-powerpoint":
|
||||
return true
|
||||
}
|
||||
ext := ""
|
||||
if dot := strings.LastIndex(name, "."); dot >= 0 {
|
||||
ext = strings.ToLower(name[dot+1:])
|
||||
}
|
||||
switch ext {
|
||||
case "doc", "docx", "xls", "xlsx", "ppt", "pptx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func providerIDFromETag(etag string) string {
|
||||
id := strings.Trim(strings.TrimSpace(etag), "\"")
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
// Google Drive file ids are alphanumeric plus _ and -.
|
||||
for _, r := range id {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if len(id) < 10 {
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// GoogleWorkspaceWebURL builds a browser editor URL for native Google Workspace files.
|
||||
func GoogleWorkspaceWebURL(mimeType, fileID string) string {
|
||||
fileID = strings.TrimSpace(fileID)
|
||||
if fileID == "" {
|
||||
return ""
|
||||
}
|
||||
switch strings.TrimSpace(mimeType) {
|
||||
case "application/vnd.google-apps.document":
|
||||
return "https://docs.google.com/document/d/" + fileID + "/edit"
|
||||
case "application/vnd.google-apps.spreadsheet":
|
||||
return "https://docs.google.com/spreadsheets/d/" + fileID + "/edit"
|
||||
case "application/vnd.google-apps.presentation":
|
||||
return "https://docs.google.com/presentation/d/" + fileID + "/edit"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func shouldOpenMountExternally(backend, mimeType, name string) bool {
|
||||
backend = NormalizeMountBackend(backend)
|
||||
if backend == "google" && isGoogleWorkspaceNativeMime(mimeType) {
|
||||
return true
|
||||
}
|
||||
if backend == "microsoft" && isMicrosoftOfficeMime(mimeType, name) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnrichMountCloudNative marks mount files that should open in the provider's web editor.
|
||||
func EnrichMountCloudNative(file *nextcloud.FileInfo, mountBackend string) {
|
||||
if file == nil || file.Type == "directory" {
|
||||
return
|
||||
}
|
||||
backend := NormalizeMountBackend(mountBackend)
|
||||
if backend == "" {
|
||||
return
|
||||
}
|
||||
file.MountBackend = backend
|
||||
if !shouldOpenMountExternally(backend, file.MimeType, file.Name) {
|
||||
return
|
||||
}
|
||||
file.OpenExternally = true
|
||||
if backend == "google" && isGoogleWorkspaceNativeMime(file.MimeType) {
|
||||
if id := providerIDFromETag(file.ETag); id != "" {
|
||||
file.ExternalURL = GoogleWorkspaceWebURL(file.MimeType, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func EnrichMountCloudNativeFiles(files []nextcloud.FileInfo, mountBackend string) []nextcloud.FileInfo {
|
||||
for i := range files {
|
||||
EnrichMountCloudNative(&files[i], mountBackend)
|
||||
}
|
||||
return files
|
||||
}
|
||||
61
internal/driveroot/cloud_native_test.go
Normal file
61
internal/driveroot/cloud_native_test.go
Normal file
@ -0,0 +1,61 @@
|
||||
package driveroot_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/driveroot"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
)
|
||||
|
||||
func TestGoogleWorkspaceWebURL(t *testing.T) {
|
||||
url := driveroot.GoogleWorkspaceWebURL("application/vnd.google-apps.document", "abc123")
|
||||
if url != "https://docs.google.com/document/d/abc123/edit" {
|
||||
t.Fatalf("unexpected url: %s", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMountCloudNativeGoogle(t *testing.T) {
|
||||
file := nextcloud.FileInfo{
|
||||
Type: "file",
|
||||
Name: "Budget",
|
||||
MimeType: "application/vnd.google-apps.spreadsheet",
|
||||
ETag: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
|
||||
}
|
||||
driveroot.EnrichMountCloudNative(&file, "googledrive")
|
||||
if !file.OpenExternally {
|
||||
t.Fatal("expected open_externally")
|
||||
}
|
||||
if file.ExternalURL == "" {
|
||||
t.Fatal("expected external_url")
|
||||
}
|
||||
if file.MountBackend != "google" {
|
||||
t.Fatalf("mount_backend=%s", file.MountBackend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMountCloudNativePersonalIgnored(t *testing.T) {
|
||||
file := nextcloud.FileInfo{
|
||||
Type: "file",
|
||||
Name: "Report.docx",
|
||||
MimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
}
|
||||
driveroot.EnrichMountCloudNative(&file, "googledrive")
|
||||
if file.OpenExternally {
|
||||
t.Fatal("personal ultidrive doc should not open externally from mount enrich without mount context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMountCloudNativeMicrosoftOffice(t *testing.T) {
|
||||
file := nextcloud.FileInfo{
|
||||
Type: "file",
|
||||
Name: "Plan.xlsx",
|
||||
MimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
}
|
||||
driveroot.EnrichMountCloudNative(&file, "onedrive")
|
||||
if !file.OpenExternally {
|
||||
t.Fatal("expected open_externally for office on onedrive mount")
|
||||
}
|
||||
if file.MountBackend != "microsoft" {
|
||||
t.Fatalf("mount_backend=%s", file.MountBackend)
|
||||
}
|
||||
}
|
||||
@ -37,6 +37,7 @@ type Mount struct {
|
||||
MountPoint string `json:"mount_point"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
ConfigEnc []byte `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@ -264,6 +265,34 @@ func (s *Store) CreateMount(ctx context.Context, p CreateMountParams) (Mount, er
|
||||
return scanMount(row)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateMountConfig(ctx context.Context, id string, configEnc []byte) error {
|
||||
if s.db == nil {
|
||||
return fmt.Errorf("database not configured")
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE drive_mounts SET config_encrypted = $2, updated_at = NOW() WHERE id = $1
|
||||
`, id, configEnc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrMountNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetMountConfig(ctx context.Context, id string) ([]byte, error) {
|
||||
if s.db == nil {
|
||||
return nil, fmt.Errorf("database not configured")
|
||||
}
|
||||
var config []byte
|
||||
err := s.db.QueryRow(ctx, `SELECT config_encrypted FROM drive_mounts WHERE id = $1`, id).Scan(&config)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrMountNotFound
|
||||
}
|
||||
return config, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateMountStatus(ctx context.Context, id, status, lastError string, ncMountID *int) error {
|
||||
if s.db == nil {
|
||||
return fmt.Errorf("database not configured")
|
||||
|
||||
@ -36,6 +36,12 @@ type FileInfo struct {
|
||||
RootKind string `json:"root_kind,omitempty"`
|
||||
RootID string `json:"root_id,omitempty"`
|
||||
Capabilities *FileCapabilities `json:"capabilities,omitempty"`
|
||||
// Mount cloud provider (google, microsoft) when file lives on an external mount.
|
||||
MountBackend string `json:"mount_backend,omitempty"`
|
||||
// ExternalURL opens the file in the provider's web editor (Docs, Office Online, …).
|
||||
ExternalURL string `json:"external_url,omitempty"`
|
||||
// OpenExternally defaults opening to ExternalURL instead of Ultidocs / OnlyOffice.
|
||||
OpenExternally bool `json:"open_externally,omitempty"`
|
||||
}
|
||||
|
||||
type ShareInfo struct {
|
||||
|
||||
194
internal/nextcloud/mount_cloud.go
Normal file
194
internal/nextcloud/mount_cloud.go
Normal file
@ -0,0 +1,194 @@
|
||||
package nextcloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MountCloudResolver resolves provider web URLs for files on external mounts.
|
||||
type MountCloudResolver struct {
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func (r *MountCloudResolver) httpClient() *http.Client {
|
||||
if r != nil && r.Client != nil {
|
||||
return r.Client
|
||||
}
|
||||
return &http.Client{Timeout: 45 * time.Second}
|
||||
}
|
||||
|
||||
// ParseOAuthAccessToken extracts a bearer token from Nextcloud external storage token JSON.
|
||||
func ParseOAuthAccessToken(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &obj); err == nil {
|
||||
if at, ok := obj["access_token"].(string); ok && strings.TrimSpace(at) != "" {
|
||||
return strings.TrimSpace(at)
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func (r *MountCloudResolver) GoogleDriveWebViewLink(ctx context.Context, accessToken, fileID string) (string, error) {
|
||||
fileID = strings.TrimSpace(fileID)
|
||||
accessToken = strings.TrimSpace(accessToken)
|
||||
if fileID == "" || accessToken == "" {
|
||||
return "", fmt.Errorf("missing google file id or token")
|
||||
}
|
||||
apiURL := "https://www.googleapis.com/drive/v3/files/" + url.PathEscape(fileID) +
|
||||
"?fields=webViewLink,mimeType&supportsAllDrives=true"
|
||||
body, err := r.providerGET(ctx, accessToken, apiURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parsed struct {
|
||||
WebViewLink string `json:"webViewLink"`
|
||||
MimeType string `json:"mimeType"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(parsed.WebViewLink) != "" {
|
||||
return strings.TrimSpace(parsed.WebViewLink), nil
|
||||
}
|
||||
// Fallback when API omits webViewLink but mime is native Google Workspace.
|
||||
return driverootGoogleWorkspaceURL(parsed.MimeType, fileID), nil
|
||||
}
|
||||
|
||||
func driverootGoogleWorkspaceURL(mimeType, fileID string) string {
|
||||
switch strings.TrimSpace(mimeType) {
|
||||
case "application/vnd.google-apps.document":
|
||||
return "https://docs.google.com/document/d/" + fileID + "/edit"
|
||||
case "application/vnd.google-apps.spreadsheet":
|
||||
return "https://docs.google.com/spreadsheets/d/" + fileID + "/edit"
|
||||
case "application/vnd.google-apps.presentation":
|
||||
return "https://docs.google.com/presentation/d/" + fileID + "/edit"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MountCloudResolver) ResolveGoogleDrivePath(ctx context.Context, accessToken, logicalPath string) (string, error) {
|
||||
segments := splitLogicalPathSegments(logicalPath)
|
||||
if len(segments) == 0 {
|
||||
return "", fmt.Errorf("empty path")
|
||||
}
|
||||
parentID := "root"
|
||||
for i, name := range segments {
|
||||
isLast := i == len(segments)-1
|
||||
id, mime, err := r.googleFindChild(ctx, accessToken, parentID, name, isLast)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isLast {
|
||||
link, err := r.GoogleDriveWebViewLink(ctx, accessToken, id)
|
||||
if err != nil {
|
||||
if built := driverootGoogleWorkspaceURL(mime, id); built != "" {
|
||||
return built, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
parentID = id
|
||||
}
|
||||
return "", fmt.Errorf("path not found")
|
||||
}
|
||||
|
||||
func (r *MountCloudResolver) googleFindChild(ctx context.Context, accessToken, parentID, name string, wantFile bool) (id, mimeType string, err error) {
|
||||
q := fmt.Sprintf("'%s' in parents and name = '%s' and trashed = false", parentID, escapeDriveQuery(name))
|
||||
if wantFile {
|
||||
q += " and mimeType != 'application/vnd.google-apps.folder'"
|
||||
} else {
|
||||
q += " and mimeType = 'application/vnd.google-apps.folder'"
|
||||
}
|
||||
listURL := "https://www.googleapis.com/drive/v3/files?supportsAllDrives=true&includeItemsFromAllDrives=true&fields=files(id,mimeType)&q=" +
|
||||
url.QueryEscape(q)
|
||||
body, err := r.providerGET(ctx, accessToken, listURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var parsed struct {
|
||||
Files []struct {
|
||||
ID string `json:"id"`
|
||||
MimeType string `json:"mimeType"`
|
||||
} `json:"files"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(parsed.Files) == 0 {
|
||||
return "", "", fmt.Errorf("google drive item not found: %s", name)
|
||||
}
|
||||
return parsed.Files[0].ID, parsed.Files[0].MimeType, nil
|
||||
}
|
||||
|
||||
func escapeDriveQuery(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "\\'")
|
||||
}
|
||||
|
||||
func (r *MountCloudResolver) ResolveMicrosoftDrivePath(ctx context.Context, accessToken, logicalPath string) (string, error) {
|
||||
segments := splitLogicalPathSegments(logicalPath)
|
||||
if len(segments) == 0 {
|
||||
return "", fmt.Errorf("empty path")
|
||||
}
|
||||
encoded := make([]string, len(segments))
|
||||
for i, seg := range segments {
|
||||
encoded[i] = url.PathEscape(seg)
|
||||
}
|
||||
itemPath := "/me/drive/root:/" + strings.Join(encoded, "/")
|
||||
apiURL := "https://graph.microsoft.com/v1.0" + itemPath + "?$select=webUrl"
|
||||
body, err := r.providerGET(ctx, accessToken, apiURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parsed struct {
|
||||
WebURL string `json:"webUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
webURL := strings.TrimSpace(parsed.WebURL)
|
||||
if webURL == "" {
|
||||
return "", fmt.Errorf("microsoft drive item has no webUrl")
|
||||
}
|
||||
return webURL, nil
|
||||
}
|
||||
|
||||
func splitLogicalPathSegments(logicalPath string) []string {
|
||||
logicalPath = strings.Trim(logicalPath, "/")
|
||||
if logicalPath == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(logicalPath, "/")
|
||||
}
|
||||
|
||||
func (r *MountCloudResolver) providerGET(ctx context.Context, accessToken, apiURL string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
resp, err := r.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("provider api %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user