Files
accounted/extensions/general/cloud-backup/lib/dropbox-client.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

274 lines
9.0 KiB
TypeScript

/**
* Minimal Dropbox content client: just enough to write one archive into the
* app folder and prove the stored bytes are the bytes we sent.
*
* Two upload paths, picked by size:
* - files/upload for anything that fits one chunk (one round trip),
* - files/upload_session/* in 8 MB chunks for the large archives, which
* avoids holding a second copy of the payload for a multipart body.
*
* Both write with `mode: overwrite`, which makes uploads idempotent and lets
* Dropbox keep its own version history (30 days on Basic, longer on paid
* plans): the same rolling-history property the Drive target relies on.
* Dropbox creates missing parent folders on write, so there is no folder
* bootstrap step and nothing to revalidate.
*
* Every upload is verified against the `content_hash` Dropbox reports. A
* silently corrupted backup is worse than a failed one.
*/
import { createHash } from 'node:crypto'
const CONTENT_API = 'https://content.dropboxapi.com/2'
/** 8 MB upload chunks: comfortably inside Dropbox's 150 MB per-request cap. */
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024
const CHUNK_MAX_ATTEMPTS = 3
/** Dropbox's content_hash is defined over 4 MB blocks: not our chunk size. */
const CONTENT_HASH_BLOCK_BYTES = 4 * 1024 * 1024
export interface DropboxUploadResult {
/** App-folder-relative path, used as the file handle in sync state. */
path: string
name: string
size_bytes: number
content_hash?: string
}
interface DropboxFileMetadata {
name: string
path_lower?: string
path_display?: string
size?: number
content_hash?: string
}
/**
* Dropbox's documented content hash: SHA-256 of the concatenated SHA-256
* digests of each 4 MB block. Computing it locally is the only way to verify
* an upload, since Dropbox reports this and no other checksum.
*/
export function dropboxContentHash(data: ArrayBuffer): string {
const buffer = Buffer.from(data)
const blockHashes: Buffer[] = []
for (let offset = 0; offset < buffer.length; offset += CONTENT_HASH_BLOCK_BYTES) {
const block = buffer.subarray(
offset,
Math.min(offset + CONTENT_HASH_BLOCK_BYTES, buffer.length)
)
blockHashes.push(createHash('sha256').update(block).digest())
}
// An empty file hashes the empty concatenation, which is what Dropbox reports.
return createHash('sha256').update(Buffer.concat(blockHashes)).digest('hex')
}
/**
* Dropbox passes call arguments in the `Dropbox-API-Arg` HTTP header, which
* must be ASCII. Swedish file and folder names (`LÄSMIG.txt`, `Testbolag AB`)
* would otherwise produce an invalid header and a 400: escape every non-ASCII
* code unit as a \\uXXXX sequence, which Dropbox unescapes server-side.
*/
/** A single backslash, spelled without an escape the tooling can mangle. */
const BACKSLASH = String.fromCharCode(92)
const ESCAPE_PREFIX = BACKSLASH + 'u'
export function asciiJson(value: unknown): string {
let out = ''
// Iterate by code point so an astral character (an emoji in a company
// name) is escaped as its two surrogate halves rather than mangled.
for (const char of JSON.stringify(value)) {
if (char.codePointAt(0)! < 128) {
out += char
continue
}
for (let i = 0; i < char.length; i++) {
out += ESCAPE_PREFIX + char.charCodeAt(i).toString(16).padStart(4, '0')
}
}
return out
}
/**
* Dropbox rejects `/ \ : ? * " < > |` in a path component, along with control
* characters and trailing dots or spaces. Company names are user-supplied, so
* a name like `Bolaget AB / Filial` must not be able to produce an invalid
* path (or escape into a sibling folder).
*/
export function sanitizeDropboxName(name: string): string {
const ILLEGAL = '/:?*"<>|' + BACKSLASH
let out = ''
for (const char of name) {
const code = char.codePointAt(0)!
if (code < 0x20 || code === 0x7f) continue
out += ILLEGAL.includes(char) ? '-' : char
}
const cleaned = out.trim().replace(/[. ]+$/, '').slice(0, 180)
return cleaned || 'foretag'
}
async function readError(res: Response): Promise<string> {
const body = await res.text().catch(() => '')
return `${res.status} ${body.slice(0, 200)}`
}
/**
* A response Dropbox will keep rejecting however many times we ask (bad path,
* insufficient scope, dead token). Retrying it wastes the cron's time budget,
* so it aborts the chunk loop immediately.
*/
class DropboxFatalError extends Error {}
/**
* Upload `data` to `path`, replacing whatever is there.
*
* `path` is app-folder-relative and must start with `/`
* (e.g. `/Testbolag AB (556000-0000)/Arkiv 2024.zip`).
*/
export async function uploadDropboxFile(
accessToken: string,
path: string,
data: ArrayBuffer
): Promise<DropboxUploadResult> {
const expected = dropboxContentHash(data)
let lastError: Error | null = null
// One full retry on a hash mismatch, mirroring the Drive target: a mismatch
// is far more likely to be a truncated transfer than a corrupted source.
for (let attempt = 0; attempt < 2; attempt++) {
const meta =
data.byteLength <= UPLOAD_CHUNK_BYTES
? await singleShotUpload(accessToken, path, data)
: await sessionUpload(accessToken, path, data)
const result: DropboxUploadResult = {
path: meta.path_display || meta.path_lower || path,
name: meta.name,
size_bytes: typeof meta.size === 'number' ? meta.size : data.byteLength,
content_hash: meta.content_hash,
}
if (!result.content_hash || result.content_hash === expected) {
return result
}
lastError = new Error(
`Dropbox upload checksum mismatch: expected ${expected}, got ${result.content_hash}`
)
}
throw lastError
}
function commitArg(path: string) {
return {
path,
mode: 'overwrite' as const,
// No desktop notification for an automated nightly backup.
mute: true,
// Never let Dropbox invent `Arkiv 2024 (1).zip`: overwrite is the contract
// the fingerprint logic depends on.
autorename: false,
}
}
async function singleShotUpload(
accessToken: string,
path: string,
data: ArrayBuffer
): Promise<DropboxFileMetadata> {
const res = await contentFetch(accessToken, '/files/upload', commitArg(path), data)
return (await res.json()) as DropboxFileMetadata
}
/**
* Chunked upload session. Transient failures are retried per chunk; the
* session itself is not resumed across a failed run, because `performSync`
* already persists per-file progress and simply re-uploads this one file.
*/
async function sessionUpload(
accessToken: string,
path: string,
data: ArrayBuffer
): Promise<DropboxFileMetadata> {
const total = data.byteLength
// Chunks are sliced off the source rather than viewed into it: a view would
// keep the whole 300 MB archive reachable from every in-flight request body.
const firstChunk = data.slice(0, UPLOAD_CHUNK_BYTES)
const startRes = await contentFetch(
accessToken,
'/files/upload_session/start',
{ close: false },
firstChunk
)
const { session_id: sessionId } = (await startRes.json()) as { session_id: string }
let offset = firstChunk.byteLength
while (offset < total) {
const end = Math.min(offset + UPLOAD_CHUNK_BYTES, total)
const chunk = data.slice(offset, end)
if (end >= total) {
const finishRes = await contentFetch(
accessToken,
'/files/upload_session/finish',
{ cursor: { session_id: sessionId, offset }, commit: commitArg(path) },
chunk
)
return (await finishRes.json()) as DropboxFileMetadata
}
await contentFetch(
accessToken,
'/files/upload_session/append_v2',
{ cursor: { session_id: sessionId, offset }, close: false },
chunk
)
offset = end
}
// Payload was an exact multiple of the chunk size: close with an empty commit.
const finishRes = await contentFetch(
accessToken,
'/files/upload_session/finish',
{ cursor: { session_id: sessionId, offset }, commit: commitArg(path) },
new ArrayBuffer(0)
)
return (await finishRes.json()) as DropboxFileMetadata
}
async function contentFetch(
accessToken: string,
endpoint: string,
arg: unknown,
body: ArrayBuffer
): Promise<Response> {
let lastErr: unknown = null
for (let attempt = 0; attempt < CHUNK_MAX_ATTEMPTS; attempt++) {
try {
const res = await fetch(`${CONTENT_API}${endpoint}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Dropbox-API-Arg': asciiJson(arg),
'Content-Type': 'application/octet-stream',
},
body,
})
if (res.ok) return res
// 429 (rate limited) and 5xx are the documented retryable responses;
// everything else is our fault and will fail identically on retry.
const detail = `Dropbox ${endpoint} failed: ${await readError(res)}`
if (res.status === 429 || res.status >= 500) {
lastErr = new Error(detail)
continue
}
throw new DropboxFatalError(detail)
} catch (err) {
if (err instanceof DropboxFatalError) throw err
lastErr = err
}
}
throw lastErr instanceof Error
? lastErr
: new Error(`Dropbox ${endpoint} failed after retries`)
}