Files
accounted/lib/documents/direct-upload.ts
T
Jakob Wennberg 749f90fe62 feat(inbox): direct-to-storage upload for files over the hosted body limit (#1551) (#2030)
Hosted uploads larger than the 4 MB multipart ceiling (Vercel's 4.5 MB request-body cap) now go POST /upload/create (signed PUT URL, rate-limited) -> PUT to the raw Storage URL -> POST /upload/complete (server-side magic-byte and size validation, sha256, WORM move, idempotent), reusing the #1378 pending-upload primitives. uploadAndExtract is split into uploadDocument + processArchivedDocument so both paths share the inbox pipeline. Dokumentinkorgen and the supplier-invoice form use the new path only above the threshold; files that fit keep the multipart route. Cap stays at 10 MB (the issue asks for 20 MB: founder call). Refs #1551
2026-08-30 11:55:42 +02:00

110 lines
4.2 KiB
TypeScript

/**
* Direct-to-storage upload for the document inbox (issue #1551).
*
* The platform rejects a request body over 4.5 MB before the route runs
* (see upload-size.ts), and a PDF cannot be shrunk the way a photo can. This
* path keeps the bytes out of the function body altogether:
*
* 1. POST /upload/create mints a short-lived signed Storage URL
* 2. PUT <upload_url> the browser sends the bytes straight to Storage
* 3. POST /upload/complete the server reads the object back out of Storage,
* hashes it, archives it and runs the normal
* inbox pipeline (extraction, inbox row)
*
* The hash is computed server-side from the stored object in step 3; nothing
* the browser says about the content is trusted. The URL from step 1 is the
* raw Storage URL on purpose: the same-origin /api/storage proxy the MCP
* tools use buffers the body inside a function and would hit the same
* ceiling.
*
* Resolves to the Response that ended the sequence: the /upload/complete
* response on success (the same `{ data }` shape as the multipart /upload),
* or the first failing step's response, so callers keep their existing
* `if (!res.ok)` handling. A Storage rejection in step 2 is surfaced as a
* synthesized error envelope carrying Storage's status, and step 3 is never
* attempted after it: an abandoned reservation leaves no document row (the
* pending object is swept on a later create).
*/
const INBOX_ROUTE_BASE = '/api/extensions/ext/invoice-inbox'
export const INBOX_UPLOAD_CREATE_URL = `${INBOX_ROUTE_BASE}/upload/create`
export const INBOX_UPLOAD_COMPLETE_URL = `${INBOX_ROUTE_BASE}/upload/complete`
export interface DirectUploadOptions {
/** Pre-match the new inbox item to a bank transaction (same as /upload). */
matchedTransactionId?: string | null
/** Bring-your-own-extraction opt-out (same as /upload's skip_extraction). */
skipExtraction?: boolean
/** Injectable for tests; defaults to the global fetch. */
fetchImpl?: typeof fetch
}
interface SignedUploadReservation {
upload_id: string
upload_url: string
expires_at: string
}
/**
* Storage answered the PUT with a failure. Wrapped in the standard envelope
* so getResponseErrorMessage() reads it like any route failure. Never a
* session-expiry false positive: notifySessionExpired keys on a header only
* the app's own 401 carries.
*/
function storageRejectedResponse(status: number): Response {
return new Response(
JSON.stringify({
error: {
code: 'INBOX_UPLOAD_STORAGE_REJECTED',
message: `Lagringstjänsten tog inte emot filen (HTTP ${status}). Försök igen.`,
message_en: `The storage service did not accept the file (HTTP ${status}). Try again.`,
},
}),
{ status, headers: { 'content-type': 'application/json' } },
)
}
export async function uploadViaSignedUrl(
file: File,
options: DirectUploadOptions = {},
): Promise<Response> {
// Wrapped rather than referenced: a detached `fetch` loses its receiver.
const fetchImpl: typeof fetch = options.fetchImpl ?? ((input, init) => fetch(input, init))
const createRes = await fetchImpl(INBOX_UPLOAD_CREATE_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
file_name: file.name,
mime_type: file.type,
size_bytes: file.size,
}),
})
if (!createRes.ok) return createRes
const created = (await createRes.json()) as { data?: Partial<SignedUploadReservation> }
const reservation = created.data
if (!reservation?.upload_id || !reservation.upload_url) {
throw new Error('Signed upload reservation is missing upload_id or upload_url')
}
const put = await fetchImpl(reservation.upload_url, {
method: 'PUT',
headers: { 'content-type': file.type, 'x-upsert': 'false' },
body: file,
})
if (!put.ok) return storageRejectedResponse(put.status)
return fetchImpl(INBOX_UPLOAD_COMPLETE_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
upload_id: reservation.upload_id,
file_name: file.name,
mime_type: file.type,
matched_transaction_id: options.matchedTransactionId ?? null,
skip_extraction: options.skipExtraction === true,
}),
})
}