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
This commit is contained in:
Jakob Wennberg
2026-08-30 11:55:42 +02:00
committed by GitHub
parent 523fba0419
commit 749f90fe62
14 changed files with 1391 additions and 53 deletions
@@ -0,0 +1,133 @@
import { describe, it, expect, vi } from 'vitest'
import {
INBOX_UPLOAD_COMPLETE_URL,
INBOX_UPLOAD_CREATE_URL,
uploadViaSignedUrl,
} from '../direct-upload'
const UPLOAD_ID = '33333333-3333-4333-8333-333333333333'
const SIGNED_URL =
'https://proj.supabase.co/storage/v1/object/upload/sign/documents/documents/c/u/pending/x.pdf?token=signed'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
function reservationResponse(): Response {
return jsonResponse({
data: { upload_id: UPLOAD_ID, upload_url: SIGNED_URL, expires_at: '2026-08-28T12:00:00.000Z' },
})
}
function fakeFile(): File {
return new File([new Uint8Array(16)], 'faktura.pdf', { type: 'application/pdf' })
}
type Call = { url: string; init: RequestInit | undefined }
function fetchSequence(responses: Array<Response | (() => Response)>) {
const calls: Call[] = []
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(input), init })
const next = responses.shift()
if (!next) throw new Error('unexpected fetch')
return typeof next === 'function' ? next() : next
}) as unknown as typeof fetch
return { fetchImpl, calls }
}
describe('uploadViaSignedUrl', () => {
it('runs create, PUT to the raw signed URL, then complete, and resolves to the complete response', async () => {
const completeRes = jsonResponse({ data: { inbox_item_id: 'inbox-1' } })
const { fetchImpl, calls } = fetchSequence([
reservationResponse(),
new Response(null, { status: 200 }),
completeRes,
])
const file = fakeFile()
const res = await uploadViaSignedUrl(file, {
fetchImpl,
matchedTransactionId: 'tx-1',
skipExtraction: true,
})
expect(res).toBe(completeRes)
expect(calls.map((c) => c.url)).toEqual([INBOX_UPLOAD_CREATE_URL, SIGNED_URL, INBOX_UPLOAD_COMPLETE_URL])
// 1. create: JSON metadata only, never the bytes
expect(calls[0].init?.method).toBe('POST')
expect(JSON.parse(String(calls[0].init?.body))).toEqual({
file_name: 'faktura.pdf',
mime_type: 'application/pdf',
size_bytes: 16,
})
// 2. PUT: the file itself, typed, no upsert onto an existing key
expect(calls[1].init?.method).toBe('PUT')
expect(calls[1].init?.headers).toEqual({ 'content-type': 'application/pdf', 'x-upsert': 'false' })
expect(calls[1].init?.body).toBe(file)
// 3. complete: the reservation plus the same options /upload takes
expect(calls[2].init?.method).toBe('POST')
expect(JSON.parse(String(calls[2].init?.body))).toEqual({
upload_id: UPLOAD_ID,
file_name: 'faktura.pdf',
mime_type: 'application/pdf',
matched_transaction_id: 'tx-1',
skip_extraction: true,
})
})
it('defaults to no matched transaction and extraction on', async () => {
const { fetchImpl, calls } = fetchSequence([
reservationResponse(),
new Response(null, { status: 200 }),
jsonResponse({ data: {} }),
])
await uploadViaSignedUrl(fakeFile(), { fetchImpl })
expect(JSON.parse(String(calls[2].init?.body))).toMatchObject({
matched_transaction_id: null,
skip_extraction: false,
})
})
it('returns the create response unchanged when the reservation is refused, and sends nothing else', async () => {
const limited = jsonResponse({ error: { code: 'RATE_LIMITED', message: 'För många' } }, 429)
const { fetchImpl, calls } = fetchSequence([limited])
const res = await uploadViaSignedUrl(fakeFile(), { fetchImpl })
expect(res).toBe(limited)
expect(calls).toHaveLength(1)
})
it('aborts before complete when Storage rejects the PUT, surfacing the status in an envelope', async () => {
const { fetchImpl, calls } = fetchSequence([
reservationResponse(),
new Response('token expired', { status: 403 }),
])
const res = await uploadViaSignedUrl(fakeFile(), { fetchImpl })
expect(calls).toHaveLength(2)
expect(res.ok).toBe(false)
expect(res.status).toBe(403)
const body = (await res.json()) as { error: { code: string; message: string } }
expect(body.error.code).toBe('INBOX_UPLOAD_STORAGE_REJECTED')
expect(body.error.message).toContain('403')
expect(body.error.message).toContain('Försök igen')
})
it('throws when the reservation is malformed rather than PUTting to nowhere', async () => {
const { fetchImpl, calls } = fetchSequence([jsonResponse({ data: { upload_id: UPLOAD_ID } })])
await expect(uploadViaSignedUrl(fakeFile(), { fetchImpl })).rejects.toThrow(/upload_url/)
expect(calls).toHaveLength(1)
})
})
@@ -2,8 +2,11 @@ import { describe, it, expect, afterEach, vi } from 'vitest'
import {
HOSTED_MAX_UPLOAD_BYTES,
HOSTED_REQUEST_BODY_LIMIT_BYTES,
INBOX_MAX_UPLOAD_BYTES,
exceedsHostedUploadLimit,
exceedsInboxUploadLimit,
formatMegabytes,
inboxTooLargeMessage,
isShrinkableImage,
tooLargeMessage,
} from '../upload-size'
@@ -46,6 +49,29 @@ describe('upload size limits', () => {
})
})
// The inbox ceiling is the route's own MAX_FILE_SIZE (10 MB), mirrored here
// because core components cannot import from the extension. Files between
// the hosted body limit and this one take the direct-to-storage path.
describe('inbox upload ceiling', () => {
it('sits above the hosted body limit and matches the route promise of 10 MB', () => {
expect(INBOX_MAX_UPLOAD_BYTES).toBe(10 * 1024 * 1024)
expect(INBOX_MAX_UPLOAD_BYTES).toBeGreaterThan(HOSTED_REQUEST_BODY_LIMIT_BYTES)
})
it('applies on every deployment: self-hosted has the same route cap', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
expect(exceedsInboxUploadLimit(INBOX_MAX_UPLOAD_BYTES + 1)).toBe(true)
expect(exceedsInboxUploadLimit(INBOX_MAX_UPLOAD_BYTES)).toBe(false)
})
it('names the actual size and the inbox ceiling, not the hosted one', () => {
const message = inboxTooLargeMessage(12 * 1024 * 1024)
expect(message).toContain('12,0 MB')
expect(message).toContain(formatMegabytes(INBOX_MAX_UPLOAD_BYTES))
expect(message).not.toContain(formatMegabytes(HOSTED_MAX_UPLOAD_BYTES))
})
})
describe('shrinkImageForUpload', () => {
function fakeFile(size: number, type: string): File {
return { size, type, name: 'kvitto.heic', lastModified: 0 } as File
+109
View File
@@ -0,0 +1,109 @@
/**
* 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,
}),
})
}
+33
View File
@@ -65,6 +65,10 @@ export function formatMegabytes(bytes: number): string {
* or an image the browser would not decode). Names both the actual size and
* the ceiling: "too large" without either is the kind of message that sends a
* user back to support rather than to a solution.
*
* Still the right message for the surfaces that post a multipart body
* (ReconciliationUnderlag, support attachments). The document inbox no
* longer refuses at this ceiling: see inboxTooLargeMessage.
*/
export function tooLargeMessage(size: number): string {
return (
@@ -72,3 +76,32 @@ export function tooLargeMessage(size: number): string {
'Fotografera om kvittot, eller komprimera PDF:en, och försök igen.'
)
}
/**
* The document inbox's own ceiling: MAX_FILE_SIZE in the invoice-inbox
* extension and MAX_DOCUMENT_SIZE in the document service, both 10 MB.
* Mirrored here because core components must not import from extensions.
*
* Between HOSTED_MAX_UPLOAD_BYTES and this one, the inbox sends the bytes
* straight to Storage through a signed URL (direct-upload.ts) so the
* platform's body cap no longer decides what can be filed; this one still
* does, on hosted and self-hosted alike.
*/
export const INBOX_MAX_UPLOAD_BYTES = 10 * 1024 * 1024
/** True when the file is over the inbox ceiling on any deployment. */
export function exceedsInboxUploadLimit(size: number): boolean {
return size > INBOX_MAX_UPLOAD_BYTES
}
/**
* The inbox's sentence for a file over its ceiling. Same shape as
* tooLargeMessage (actual size, then the limit), but the limit it names is
* the one that actually applies on that surface now.
*/
export function inboxTooLargeMessage(size: number): string {
return (
`Filen är ${formatMegabytes(size)} och gränsen för dokumentinkorgen är ${formatMegabytes(INBOX_MAX_UPLOAD_BYTES)}. ` +
'Komprimera PDF:en, eller dela upp den, och försök igen.'
)
}