Files
accounted/extensions/general/cloud-backup/lib/dropbox-client.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01: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`)
}