fbd4b992f5
* fix(privacy): make privacy policy page dark mode friendly Replace the hardcoded light gradient background with bg-background and add dark:prose-invert to the prose blocks so body text is readable on dark cards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cloud-backup): sync archives to Dropbox alongside Google Drive Introduce a CloudStorageProvider interface so performSync builds the archive set once and talks to storage only through it. Google Drive keeps its existing behaviour; Dropbox is a second implementation, so the compliance-relevant half (fingerprints, per-year layout, size fallback, progressive persistence) cannot drift between targets. Dropbox uses App folder access, matching the drive.file scope's "only what the app created" guarantee. Uploads are single-shot under 8 MB and chunked upload sessions above, every write verified against Dropbox's content_hash. Call arguments are ASCII-escaped per UTF-16 code unit so Swedish file names survive the Dropbox-API-Arg header. Each provider owns its extension_data keys, schedule, failure counter and alert throttle, so a dead Dropbox token cannot pause a healthy Drive backup. The google_drive_* keys and the /oauth/callback path are untouched: both are wire format for already-connected companies. isConfigured() gates /connect only. A deployment that loses its OAuth credentials must not trap users with a connection they cannot remove or a schedule they cannot switch off. Requires DROPBOX_APP_KEY and DROPBOX_APP_SECRET; the provider row renders disabled without them. No migration: state is extension_data JSON throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: remove merge-conflict markers committed in DECISIONS.md The merge that brought main into this branch staged DECISIONS.md while it still carried conflict markers, so cdc3a513 shipped an unresolved hunk (compliance swarm ISO 27001 A.8.32). DECISIONS.md is an append-only log, so both sides are kept: main's systemdokumentation entry followed by this branch's Dropbox entries. No decision was dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
196 lines
6.3 KiB
TypeScript
196 lines
6.3 KiB
TypeScript
/**
|
|
* Minimal Dropbox OAuth 2.0 client for the cloud-backup extension.
|
|
*
|
|
* Access type: **App folder**. Dropbox confines the app to its own
|
|
* `Apps/<app name>/` directory, so every path we use is relative to that
|
|
* folder and the app can never read the rest of the user's Dropbox. This
|
|
* mirrors the privacy posture of the Google `drive.file` scope.
|
|
*
|
|
* `token_access_type=offline` is what makes Dropbox return a refresh token;
|
|
* without it the grant yields a short-lived access token only and the nightly
|
|
* cron would stop working after a few hours.
|
|
*/
|
|
|
|
import {
|
|
fetchWithTimeout,
|
|
OAUTH_TIMEOUT_MS,
|
|
OAUTH_REVOKE_TIMEOUT_MS,
|
|
} from '@/lib/http/fetch-with-timeout'
|
|
import { CloudTokenRefreshError } from './cloud-provider'
|
|
|
|
const AUTH_ENDPOINT = 'https://www.dropbox.com/oauth2/authorize'
|
|
const TOKEN_ENDPOINT = 'https://api.dropboxapi.com/oauth2/token'
|
|
const REVOKE_ENDPOINT = 'https://api.dropboxapi.com/2/auth/token/revoke'
|
|
const ACCOUNT_ENDPOINT = 'https://api.dropboxapi.com/2/users/get_current_account'
|
|
|
|
/**
|
|
* Write + read the app folder, and read the account identity for the "connected
|
|
* as" line in the UI. Read access is requested because a restore-side feature
|
|
* (and support diagnosing a partial backup) needs to list what is actually
|
|
* there; without it the app is write-only and cannot verify its own output.
|
|
*/
|
|
const SCOPES = ['files.content.write', 'files.content.read', 'account_info.read']
|
|
|
|
export interface DropboxOAuthEnv {
|
|
appKey: string
|
|
appSecret: string
|
|
redirectUri: string
|
|
}
|
|
|
|
/** Callback path, kept next to the URL builder so the two cannot drift. */
|
|
export const DROPBOX_CALLBACK_PATH = '/oauth/dropbox/callback'
|
|
|
|
export function isDropboxOAuthConfigured(): boolean {
|
|
return Boolean(process.env.DROPBOX_APP_KEY && process.env.DROPBOX_APP_SECRET)
|
|
}
|
|
|
|
export function getDropboxOAuthEnv(origin: string): DropboxOAuthEnv {
|
|
const appKey = process.env.DROPBOX_APP_KEY
|
|
const appSecret = process.env.DROPBOX_APP_SECRET
|
|
if (!appKey || !appSecret) {
|
|
throw new Error(
|
|
'Dropbox OAuth is not configured: set DROPBOX_APP_KEY and DROPBOX_APP_SECRET'
|
|
)
|
|
}
|
|
return {
|
|
appKey,
|
|
appSecret,
|
|
redirectUri: `${origin}/api/extensions/ext/cloud-backup${DROPBOX_CALLBACK_PATH}`,
|
|
}
|
|
}
|
|
|
|
export function buildDropboxAuthorizationUrl(
|
|
env: DropboxOAuthEnv,
|
|
state: string
|
|
): string {
|
|
const params = new URLSearchParams({
|
|
client_id: env.appKey,
|
|
redirect_uri: env.redirectUri,
|
|
response_type: 'code',
|
|
token_access_type: 'offline',
|
|
scope: SCOPES.join(' '),
|
|
// Re-consent on every connect so a reconnect always yields a fresh refresh
|
|
// token, matching the Google flow's `prompt=consent`.
|
|
force_reapprove: 'true',
|
|
state,
|
|
})
|
|
return `${AUTH_ENDPOINT}?${params.toString()}`
|
|
}
|
|
|
|
export interface DropboxTokenExchangeResult {
|
|
access_token: string
|
|
refresh_token: string
|
|
expires_in: number
|
|
account_id?: string
|
|
}
|
|
|
|
export async function exchangeDropboxCodeForTokens(
|
|
env: DropboxOAuthEnv,
|
|
code: string
|
|
): Promise<DropboxTokenExchangeResult> {
|
|
const body = new URLSearchParams({
|
|
code,
|
|
grant_type: 'authorization_code',
|
|
client_id: env.appKey,
|
|
client_secret: env.appSecret,
|
|
redirect_uri: env.redirectUri,
|
|
})
|
|
const res = await fetchWithTimeout(
|
|
TOKEN_ENDPOINT,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: body.toString(),
|
|
},
|
|
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Dropbox token exchange' }
|
|
)
|
|
if (!res.ok) {
|
|
const errText = await res.text()
|
|
throw new Error(`Dropbox token exchange failed: ${res.status} ${errText}`)
|
|
}
|
|
const json = (await res.json()) as DropboxTokenExchangeResult
|
|
if (!json.refresh_token) {
|
|
throw new Error(
|
|
'No refresh token returned: the authorization request must use ' +
|
|
'token_access_type=offline.'
|
|
)
|
|
}
|
|
return json
|
|
}
|
|
|
|
/**
|
|
* Thrown when Dropbox's token endpoint rejects a refresh. Shares
|
|
* {@link CloudTokenRefreshError}'s `invalid_grant` detection: Dropbox returns
|
|
* `400 {"error": "invalid_grant"}` once the user disconnects the app.
|
|
*/
|
|
export class DropboxTokenRefreshError extends CloudTokenRefreshError {
|
|
constructor(status: number, body: string) {
|
|
super('Dropbox', status, body)
|
|
this.name = 'DropboxTokenRefreshError'
|
|
}
|
|
}
|
|
|
|
export async function refreshDropboxAccessToken(
|
|
env: DropboxOAuthEnv,
|
|
refreshToken: string
|
|
): Promise<{ access_token: string; expires_in: number }> {
|
|
const body = new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
client_id: env.appKey,
|
|
client_secret: env.appSecret,
|
|
})
|
|
const res = await fetchWithTimeout(
|
|
TOKEN_ENDPOINT,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: body.toString(),
|
|
},
|
|
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Dropbox token refresh' }
|
|
)
|
|
if (!res.ok) {
|
|
const errText = await res.text()
|
|
throw new DropboxTokenRefreshError(res.status, errText)
|
|
}
|
|
return (await res.json()) as { access_token: string; expires_in: number }
|
|
}
|
|
|
|
/**
|
|
* Revoke the grant. Dropbox revokes by *access* token (there is no
|
|
* refresh-token revoke endpoint), so the caller mints a short-lived one first.
|
|
* Best-effort: a failed revoke must not block the local disconnect.
|
|
*/
|
|
export async function revokeDropboxToken(accessToken: string): Promise<void> {
|
|
try {
|
|
await fetchWithTimeout(
|
|
REVOKE_ENDPOINT,
|
|
{
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
},
|
|
{ timeoutMs: OAUTH_REVOKE_TIMEOUT_MS, description: 'Dropbox token revoke' }
|
|
)
|
|
} catch {
|
|
// Swallow timeouts and network errors so disconnect flows still complete locally.
|
|
}
|
|
}
|
|
|
|
export async function fetchDropboxAccountEmail(accessToken: string): Promise<string> {
|
|
// An RPC endpoint taking no arguments: send no body and no Content-Type,
|
|
// otherwise Dropbox rejects the call as a malformed request.
|
|
const res = await fetchWithTimeout(
|
|
ACCOUNT_ENDPOINT,
|
|
{
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
},
|
|
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Dropbox account fetch' }
|
|
)
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to fetch Dropbox account info: ${res.status}`)
|
|
}
|
|
const json = (await res.json()) as { email?: string }
|
|
return json.email || 'unknown@dropbox'
|
|
}
|