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 }