Files
kite/frontend/src/api/cloud.js
T
curo1305 5c0bc2a3b4 feat(13-04): upgrade Google Drive scope to drive, add health/reconnect/content API helpers
- connections.py: Update Google Drive OAuth to broader `drive` scope (D-17)
  instead of `drive.file` so authorized users can operate on all existing Drive
  items, not just files created by this app. Both initiation and callback flows
  updated to keep scopes consistent.
- schemas.py: Add Phase 13 typed response schemas — ConnectionHealthOut, ReconnectOut,
  ContentResultOut, MutationResultOut, and request schemas CreateFolderRequest,
  RenameItemRequest, MoveItemRequest. All credential-free (T-13-14, CONN-03).
- api/cloud.js: Add centralized frontend helpers for getConnectionHealth,
  reconnectCloudConnection, testCloudConnection (D-12, D-13), plus authorized
  cloud content and mutation helpers: openCloudFile, previewCloudFile,
  createCloudFolder, renameCloudItem, moveCloudItem, deleteCloudItem, uploadCloudFile.

All reconnect/health/test tests pass (14/14). Security suite passes (19/19).
2026-06-22 18:49:47 +02:00

236 lines
8.5 KiB
JavaScript

/**
* Cloud Storage API — connections, OAuth initiation, folder browsing, config.
*
* Consumers: stores/cloudConnections.js, SettingsCloudTab.vue,
* CloudCredentialModal.vue, CloudProviderTreeItem.vue,
* CloudFolderTreeItem.vue
*/
import { request, jsonRequest } from './utils.js'
export function listCloudConnections() {
return request('/api/cloud/connections')
}
export function disconnectCloud(id) {
return request(`/api/cloud/connections/${id}`, { method: 'DELETE' })
}
export function connectWebDav(provider, serverUrl, username, password) {
return jsonRequest('/api/cloud/connections/webdav', 'POST', {
provider,
server_url: serverUrl,
username,
password,
})
}
export function updateDefaultStorage(backend) {
return jsonRequest('/api/users/me/default-storage', 'PATCH', { backend })
}
/**
* Browse a cloud folder by connection UUID and optional parent_ref.
* connectionId is always a UUID; parentRef is a provider path string (default '').
*/
export function getCloudFoldersByConnectionId(connectionId, parentRef = '') {
const qs = parentRef ? `?parent_ref=${encodeURIComponent(parentRef)}` : ''
return request(`/api/cloud/connections/${connectionId}/items${qs}`)
}
/** @deprecated Use getCloudFoldersByConnectionId instead. */
export function getCloudFolders(provider, folderId) {
return request(`/api/cloud/folders/${provider}/${folderId}`)
}
/**
* Rename a cloud connection's display name.
*/
export function renameCloudConnection(id, displayName) {
return jsonRequest(`/api/cloud/connections/${id}`, 'PATCH', { display_name: displayName })
}
/**
* Initiate OAuth flow for Google Drive or OneDrive.
*
* Returns a JSON object {url: "<authorization_url>"} from the backend.
* The caller is responsible for navigating: window.location.href = data.url
*
* Using request() (not bare window.location.href) ensures the Bearer header
* is injected and the 401→refresh retry path fires if the token has expired.
* See plan 05-10 trust boundary: frontend→/api/cloud/oauth/initiate/{provider}.
*/
export function initiateOAuth(provider) {
return request(`/api/cloud/oauth/initiate/${provider}`)
}
/**
* Fetch non-secret configuration for a WebDAV/Nextcloud connection (edit flow).
*
* Returns {id, provider, server_url, connection_username} — never the password.
* Used to pre-populate the Edit modal when re-editing an existing connection.
*/
export function getConnectionConfig(connectionId) {
return request(`/api/cloud/connections/${connectionId}/config`)
}
/**
* Update credentials for an existing WebDAV/Nextcloud connection (owner-scoped).
*
* Only fields included are changed. Omitting password preserves the existing secret.
* Backend re-validates the URL and runs a health-check before committing.
*/
export function updateWebDavCredentials(connectionId, { serverUrl, username, password }) {
const body = {}
if (serverUrl !== undefined) body.server_url = serverUrl
if (username !== undefined) body.username = username
if (password !== undefined && password !== '') body.password = password
return jsonRequest(`/api/cloud/connections/${connectionId}/credentials`, 'PUT', body)
}
// ── Phase 13: Connection health, reconnect, and test ─────────────────────────
/**
* Get the health status of a cloud connection without probing the provider.
*
* D-12: Health is available explicitly (not inferred from browse).
* Returns {status, connection_id, provider, display_name}.
* status: 'healthy' | 'degraded' | 'auth_failed' | 'offline'
*/
export function getConnectionHealth(connectionId) {
return request(`/api/cloud/connections/${connectionId}/health`)
}
/**
* Reconnect an AUTH_FAILED connection in-place.
*
* CONN-01: Patches the existing row — no new connection row created.
* CONN-02: Re-encrypts refreshed credentials in place.
* CONN-03: Response is credential-free.
* D-14: Cached metadata preserved as stale; caches invalidated server-side.
*/
export function reconnectCloudConnection(connectionId) {
return jsonRequest(`/api/cloud/connections/${connectionId}/reconnect`, 'POST', {})
}
/**
* Explicitly test a cloud connection by probing the provider.
*
* D-13: Explicit Test action — not triggered on every folder navigation.
* Updates the connection status row and returns the new health status.
*/
export function testCloudConnection(connectionId) {
return jsonRequest(`/api/cloud/connections/${connectionId}/test`, 'POST', {})
}
// ── Phase 13: Authorized cloud content access ─────────────────────────────────
/**
* Open a cloud file via a DocuVault-authorized URL.
*
* D-02: Provider credentials and raw provider URLs are never exposed.
* Returns {kind: 'open', url: '<authorized-docuvault-url>'}.
* The caller must navigate to the returned URL — never window.open to a provider URL.
*/
export function openCloudFile(connectionId, itemId) {
return request(`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/open`)
}
/**
* Get an in-app preview of a cloud file (binary formats only).
*
* D-18: Preview is binary-only. Unsupported formats return
* {kind: 'unsupported_preview', reason: 'unsupported_format'} and the caller
* should fall back to openCloudFile() for authorized download.
* D-02: No raw provider URL or credentials in the response.
*/
export function previewCloudFile(connectionId, itemId) {
return request(`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/preview`)
}
// ── Phase 13: Cloud mutations ─────────────────────────────────────────────────
/**
* Create a folder in cloud storage.
*
* D-05: Collision auto-naming: 'Projects (1)', 'Projects (2)', etc.
* Returns {kind: 'folder', provider_item_id, name} on success.
* Returns {kind: 'unsupported_operation', reason: 'provider_unsupported'} when
* the provider does not support folder creation.
*/
export function createCloudFolder(connectionId, parentRef, name) {
return jsonRequest(`/api/cloud/connections/${connectionId}/folders`, 'POST', {
parent_ref: parentRef,
name,
})
}
/**
* Rename a cloud item.
*
* D-05: Counter-suffix collision policy applies.
* D-07: etag guards against operating on stale metadata.
* Returns {kind: 'stale', reason: 'item_changed'} when externally changed.
* Returns {kind: 'renamed', name} on success.
*/
export function renameCloudItem(connectionId, itemId, newName, etag) {
return jsonRequest(
`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/rename`,
'PATCH',
{ new_name: newName, etag },
)
}
/**
* Move a cloud item within the same connection.
*
* D-08: Moves are restricted to the same cloud connection.
* D-09: Self and descendant destinations are rejected.
* Returns {kind: 'moved', parent_ref} on success.
* Returns {kind: 'invalid_destination', reason: '...'} for invalid moves.
*/
export function moveCloudItem(connectionId, itemId, destinationParentRef, etag) {
return jsonRequest(
`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/move`,
'POST',
{ destination_parent_ref: destinationParentRef, etag },
)
}
/**
* Delete a cloud item (trash preferred, permanent when unavailable).
*
* D-11: Response discloses whether trash or permanent delete was performed via
* {kind: 'deleted', reason: 'trashed' | 'permanent'}.
* D-10: Callers must confirm before calling — the API deletes immediately.
*/
export function deleteCloudItem(connectionId, itemId) {
return request(
`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}`,
{ method: 'DELETE' },
)
}
/**
* Upload a file to a cloud folder.
*
* D-03: Same-name upload returns {kind: 'conflict', reason: 'name_collision'} —
* never overwrites silently.
* D-04: Caller manages the sequential upload queue; this function uploads one file.
*
* @param {string} connectionId - Connection UUID
* @param {string} parentRef - Parent folder provider_item_id
* @param {string} filename - Target filename
* @param {File|Blob} file - File bytes
*/
export function uploadCloudFile(connectionId, parentRef, filename, file) {
const formData = new FormData()
formData.append('file', file, filename)
formData.append('parent_ref', parentRef ?? '')
formData.append('filename', filename)
return request(`/api/cloud/connections/${connectionId}/items/upload`, {
method: 'POST',
body: formData,
})
}