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>
274 lines
9.0 KiB
TypeScript
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`)
|
|
}
|