diff --git a/internal/api/drive/mount_cloud_service.go b/internal/api/drive/mount_cloud_service.go new file mode 100644 index 0000000..8f7e699 --- /dev/null +++ b/internal/api/drive/mount_cloud_service.go @@ -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 +} diff --git a/internal/api/drive/mounts_service.go b/internal/api/drive/mounts_service.go index ab6d88c..03b800a 100644 --- a/internal/api/drive/mounts_service.go +++ b/internal/api/drive/mounts_service.go @@ -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) } diff --git a/internal/api/drive/org_mount_handlers.go b/internal/api/drive/org_mount_handlers.go index f613eed..adb6b1c 100644 --- a/internal/api/drive/org_mount_handlers.go +++ b/internal/api/drive/org_mount_handlers.go @@ -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) diff --git a/internal/api/drive/service_roots.go b/internal/api/drive/service_roots.go index 0b81d99..a5ca6e3 100644 --- a/internal/api/drive/service_roots.go +++ b/internal/api/drive/service_roots.go @@ -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 { diff --git a/internal/driveroot/cloud_native.go b/internal/driveroot/cloud_native.go new file mode 100644 index 0000000..00008f5 --- /dev/null +++ b/internal/driveroot/cloud_native.go @@ -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 +} diff --git a/internal/driveroot/cloud_native_test.go b/internal/driveroot/cloud_native_test.go new file mode 100644 index 0000000..d42e210 --- /dev/null +++ b/internal/driveroot/cloud_native_test.go @@ -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) + } +} diff --git a/internal/drivestore/store.go b/internal/drivestore/store.go index 66ee046..b57976d 100644 --- a/internal/drivestore/store.go +++ b/internal/drivestore/store.go @@ -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") diff --git a/internal/nextcloud/drive.go b/internal/nextcloud/drive.go index edb3703..2fb4b25 100644 --- a/internal/nextcloud/drive.go +++ b/internal/nextcloud/drive.go @@ -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 { diff --git a/internal/nextcloud/mount_cloud.go b/internal/nextcloud/mount_cloud.go new file mode 100644 index 0000000..e038f6f --- /dev/null +++ b/internal/nextcloud/mount_cloud.go @@ -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 +}