ultisuite-backend/internal/nextcloud/mount_cloud.go
R3D347HR4Y 857b9afc43
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
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.
2026-06-13 13:44:43 +02:00

195 lines
5.7 KiB
Go

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
}