Files
accounted/extensions/general/cloud-backup/lib/dropbox-provider.ts
T
Mattsson fbd4b992f5 Add/db and speed (#1243)
* 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>
2026-07-27 16:49:24 +02:00

149 lines
5.0 KiB
TypeScript

/**
* Dropbox as a cloud backup target.
*
* The app is **app-folder** scoped, so every path here is relative to
* `Apps/<app name>/` in the user's Dropbox and the app can never see anything
* else they store there. That gives the same "we only touch what we created"
* guarantee as the Google `drive.file` scope.
*
* Consequences for the sync engine, all handled here:
* - There is no root folder to create: Dropbox's own app folder plays that
* role, so company folders sit directly inside it (Drive needs the extra
* `gnubok/` level because it writes into the user's whole Drive).
* - Files are addressed by path, not id, and writes are `overwrite`. Nothing
* can go stale, so `prepareTarget` makes no network call and the
* "user deleted the file" recovery the Drive target needs has no analogue.
*/
import {
buildDropboxAuthorizationUrl,
exchangeDropboxCodeForTokens,
fetchDropboxAccountEmail,
getDropboxOAuthEnv,
isDropboxOAuthConfigured,
refreshDropboxAccessToken,
revokeDropboxToken,
DROPBOX_CALLBACK_PATH,
} from './dropbox-oauth'
import { sanitizeDropboxName, uploadDropboxFile } from './dropbox-client'
import type {
CloudStorageProvider,
PreparedTarget,
PrepareTargetParams,
PutFileParams,
PutFileResult,
} from './cloud-provider'
import type { CloudConnection } from '../types'
export const DROPBOX_CONNECTION_KEY = 'dropbox_connection'
export const DROPBOX_LAST_SYNC_KEY = 'dropbox_last_sync'
export const DROPBOX_SCHEDULE_KEY = 'dropbox_schedule'
/**
* Link into the Dropbox web UI.
*
* App-folder scoped calls only ever see app-relative paths, so the API cannot
* tell us where the app folder sits in the user's account. `Apps/<app name>`
* is the answer, and the app name is chosen when the Dropbox app is
* registered: a self-hoster registers their own. `DROPBOX_APP_FOLDER_NAME`
* lets a deployment state it and get a deep link; without it we send the user
* to `Apps/`, which is always correct and one click away. Never guess the
* name: a link into the wrong folder reads as a lost backup.
*/
function folderLink(companyFolderPath: string): string {
const appFolder = process.env.DROPBOX_APP_FOLDER_NAME
const base = 'https://www.dropbox.com/home/Apps'
if (!appFolder) return base
return `${base}/${encodeURIComponent(appFolder)}${companyFolderPath
.split('/')
.map(encodeURIComponent)
.join('/')}`
}
export const dropboxProvider: CloudStorageProvider = {
id: 'dropbox',
label: 'Dropbox',
keys: {
connection: DROPBOX_CONNECTION_KEY,
lastSync: DROPBOX_LAST_SYNC_KEY,
schedule: DROPBOX_SCHEDULE_KEY,
},
callbackPath: DROPBOX_CALLBACK_PATH,
isConfigured: isDropboxOAuthConfigured,
buildAuthorizationUrl(origin, state) {
return buildDropboxAuthorizationUrl(getDropboxOAuthEnv(origin), state)
},
async exchangeCode(origin, code) {
const tokens = await exchangeDropboxCodeForTokens(getDropboxOAuthEnv(origin), code)
const email = await fetchDropboxAccountEmail(tokens.access_token)
return { refreshToken: tokens.refresh_token, accountLabel: email }
},
async revoke(refreshToken, origin) {
// Dropbox revokes by access token, so mint a short-lived one first. The
// whole thing is best-effort: a dead refresh token is already revoked.
try {
const env = getDropboxOAuthEnv(origin)
const refreshed = await refreshDropboxAccessToken(env, refreshToken)
await revokeDropboxToken(refreshed.access_token)
} catch {
// Swallow: the local disconnect must complete regardless.
}
},
async refreshAccessToken(refreshToken, origin) {
const refreshed = await refreshDropboxAccessToken(
getDropboxOAuthEnv(origin),
refreshToken
)
return refreshed.access_token
},
/**
* Pure path derivation: Dropbox creates missing parent folders on upload, so
* there is nothing to create up front and nothing that can be trashed
* underneath us.
*/
async prepareTarget({
connection,
companyLabel,
}: PrepareTargetParams): Promise<PreparedTarget> {
const companyFolderPath = `/${sanitizeDropboxName(companyLabel)}`
const patch: Partial<CloudConnection> | null =
connection.company_folder_path === companyFolderPath
? null
: { company_folder_path: companyFolderPath }
return {
target: {
folderId: companyFolderPath,
webViewLink: folderLink(companyFolderPath),
},
connectionPatch: patch,
}
},
/**
* `previousId` is ignored: the path is derived from the file name, and an
* overwrite write is correct whether or not the file is already there. That
* also means a file the user deleted in Dropbox simply reappears on the next
* sync, with no recovery path needed.
*/
async putFile({
accessToken,
target,
name,
data,
}: PutFileParams): Promise<PutFileResult> {
const path = `${target.folderId}/${sanitizeDropboxName(name)}`
const uploaded = await uploadDropboxFile(accessToken, path, data)
return {
id: uploaded.path,
name: uploaded.name,
size_bytes: uploaded.size_bytes,
}
},
}