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
@@ -77,8 +77,13 @@ import {
getResponseErrorMessage,
} from '@/lib/errors/get-error-message'
import { notifySessionExpired } from '@/lib/auth/session-timeout-shared'
import { exceedsHostedUploadLimit, tooLargeMessage } from '@/lib/documents/upload-size'
import {
exceedsHostedUploadLimit,
exceedsInboxUploadLimit,
inboxTooLargeMessage,
} from '@/lib/documents/upload-size'
import { shrinkImageForUpload } from '@/lib/documents/shrink-image'
import { uploadViaSignedUrl } from '@/lib/documents/direct-upload'
type AccountingMethod = 'accrual' | 'cash'
@@ -879,25 +884,28 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
) => {
// A phone photo is routinely larger than the request body the platform
// will carry, and it rejects the upload itself, before the route can say
// anything useful about it. Shrink what can be shrunk, and refuse the rest
// here, where we can name the size instead of letting the transfer fail.
// anything useful about it. Shrink what can be shrunk; what cannot be (a
// scanned PDF) goes straight to Storage through a signed URL instead of a
// multipart body. Only the inbox's own ceiling refuses anything now, here,
// where we can name the size instead of letting the transfer fail.
const file = exceedsHostedUploadLimit(original.size)
? await shrinkImageForUpload(original)
: original
if (exceedsHostedUploadLimit(file.size)) {
if (exceedsInboxUploadLimit(file.size)) {
reportUploadFailure({
status: 0,
size: file.size,
type: file.type || 'unknown',
reason: 'over hosted body limit, refused client-side',
reason: 'over inbox ceiling, refused client-side',
})
toast({
title: 'Uppladdning misslyckades',
description: tooLargeMessage(file.size),
description: inboxTooLargeMessage(file.size),
variant: 'destructive',
})
return undefined
}
const directToStorage = exceedsHostedUploadLimit(file.size)
// Optimistic placeholder: gives the user an immediate visual response
// for the 3-8s while extraction runs. Removed once the real row arrives.
@@ -929,12 +937,17 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}
setIsUploading(true)
try {
const fd = new FormData()
fd.append('file', file)
const res = await fetch('/api/extensions/ext/invoice-inbox/upload', {
method: 'POST',
body: fd,
})
let res: Response
if (directToStorage) {
res = await uploadViaSignedUrl(file)
} else {
const fd = new FormData()
fd.append('file', file)
res = await fetch('/api/extensions/ext/invoice-inbox/upload', {
method: 'POST',
body: fd,
})
}
if (!res.ok) throw await resolveFailure(res)
const json = await res.json()
if (json.data?.extraction_skipped) {