feat(drive): implement external URL resolution for mounted cloud files
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run

- 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:
R3D347HR4Y 2026-06-13 13:44:43 +02:00
parent 951c88b1ca
commit 857b9afc43
9 changed files with 564 additions and 1 deletions

View 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
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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 {

View 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
}

View 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)
}
}

View File

@ -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")

View File

@ -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 {

View 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
}