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:
@@ -696,10 +696,158 @@ describe('model-free signed document uploads', () => {
|
||||
'invoice.pdf',
|
||||
'application/pdf',
|
||||
),
|
||||
).rejects.toThrow(/kunde inte verifieras/)
|
||||
).rejects.toMatchObject({
|
||||
code: 'DOC_UPLOAD_INVALID_CONTENT',
|
||||
message: expect.stringMatching(/kunde inte verifieras/),
|
||||
messageSv: expect.stringMatching(/kunde inte verifieras/),
|
||||
})
|
||||
expect(remove).toHaveBeenCalledWith([pendingPath])
|
||||
})
|
||||
|
||||
it('codes an empty pending object as DOC_UPLOAD_EMPTY and removes it', async () => {
|
||||
results = [{ data: null, error: null }]
|
||||
const pendingPath = buildPendingDocumentStoragePath(company, user, uploadId, 'invoice.pdf')
|
||||
const remove = vi.fn().mockResolvedValue({ data: [], error: null })
|
||||
serviceClientOverride = makeClient({
|
||||
download: vi.fn().mockResolvedValue({ data: new Blob([]), error: null }),
|
||||
remove,
|
||||
})
|
||||
|
||||
await expect(
|
||||
completePendingDocumentUpload(makeClient() as never, company, user, uploadId, 'invoice.pdf', 'application/pdf'),
|
||||
).rejects.toMatchObject({ code: 'DOC_UPLOAD_EMPTY' })
|
||||
expect(remove).toHaveBeenCalledWith([pendingPath])
|
||||
})
|
||||
|
||||
// The insert payload of a completion: from() call #0 is findReservedDocument,
|
||||
// #1 the insert (no dedupe lookup in between unless opted in).
|
||||
function insertPayloadOf(client: ReturnType<typeof makeClient>, fromIndex: number) {
|
||||
const builder = client.from.mock.results[fromIndex]?.value as { insert: ReturnType<typeof vi.fn> }
|
||||
return builder.insert.mock.calls[0]?.[0] as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
it("stamps upload_source 'api' by default (the MCP tools' provenance)", async () => {
|
||||
const buffer = pdfBuffer('api upload')
|
||||
const document = makeDocumentAttachment({ id: uploadId, sha256_hash: await computeSHA256(buffer) })
|
||||
results = [
|
||||
{ data: null, error: null },
|
||||
{ data: document, error: null },
|
||||
]
|
||||
serviceClientOverride = makeClient({
|
||||
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
|
||||
})
|
||||
const client = makeClient()
|
||||
|
||||
await completePendingDocumentUpload(client as never, company, user, uploadId, 'invoice.pdf', 'application/pdf')
|
||||
|
||||
expect(insertPayloadOf(client, 1)?.upload_source).toBe('api')
|
||||
})
|
||||
|
||||
it("stamps upload_source 'file_upload' for the browser direct-to-storage path", async () => {
|
||||
const buffer = pdfBuffer('browser upload')
|
||||
const document = makeDocumentAttachment({ id: uploadId, sha256_hash: await computeSHA256(buffer) })
|
||||
results = [
|
||||
{ data: null, error: null },
|
||||
{ data: document, error: null },
|
||||
]
|
||||
serviceClientOverride = makeClient({
|
||||
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
|
||||
})
|
||||
const client = makeClient()
|
||||
|
||||
await completePendingDocumentUpload(
|
||||
client as never,
|
||||
company,
|
||||
user,
|
||||
uploadId,
|
||||
'invoice.pdf',
|
||||
'application/pdf',
|
||||
undefined,
|
||||
{ uploadSource: 'file_upload' },
|
||||
)
|
||||
|
||||
expect(insertPayloadOf(client, 1)?.upload_source).toBe('file_upload')
|
||||
// No content-dedupe lookup unless asked for: exactly two from() calls.
|
||||
expect(client.from).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('opt-in content dedupe returns the existing document and removes the pending object', async () => {
|
||||
const buffer = pdfBuffer('already archived')
|
||||
const existingId = '55555555-5555-4555-8555-555555555555'
|
||||
const existing = makeDocumentAttachment({
|
||||
id: existingId,
|
||||
company_id: company,
|
||||
sha256_hash: await computeSHA256(buffer),
|
||||
})
|
||||
results = [
|
||||
{ data: null, error: null }, // findReservedDocument
|
||||
{ data: [existing], error: null }, // dedupe lookup
|
||||
]
|
||||
const move = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
const remove = vi.fn().mockResolvedValue({ data: [], error: null })
|
||||
serviceClientOverride = makeClient({
|
||||
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
|
||||
move,
|
||||
remove,
|
||||
})
|
||||
const client = makeClient()
|
||||
|
||||
const completed = await completePendingDocumentUpload(
|
||||
client as never,
|
||||
company,
|
||||
user,
|
||||
uploadId,
|
||||
'invoice.pdf',
|
||||
'application/pdf',
|
||||
undefined,
|
||||
{ dedupeByContent: true },
|
||||
)
|
||||
|
||||
expect(completed.document.id).toBe(existingId)
|
||||
expect(completed.document.deduplicated).toBe(true)
|
||||
expect(remove).toHaveBeenCalledWith([buildPendingDocumentStoragePath(company, user, uploadId, 'invoice.pdf')])
|
||||
expect(move).not.toHaveBeenCalled()
|
||||
// findReservedDocument + dedupe lookup, and never an insert
|
||||
expect(client.from).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the SQLSTATE on a rejected document insert so callers can map an RLS denial', async () => {
|
||||
const buffer = pdfBuffer('viewer upload')
|
||||
results = [
|
||||
{ data: null, error: null }, // findReservedDocument
|
||||
{
|
||||
data: null,
|
||||
error: {
|
||||
code: '42501',
|
||||
message: 'new row violates row-level security policy for table "document_attachments"',
|
||||
},
|
||||
},
|
||||
{ data: null, error: null }, // concurrent-completion check
|
||||
]
|
||||
const remove = vi.fn().mockResolvedValue({ data: [], error: null })
|
||||
serviceClientOverride = makeClient({
|
||||
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
|
||||
remove,
|
||||
})
|
||||
|
||||
await expect(
|
||||
completePendingDocumentUpload(makeClient() as never, company, user, uploadId, 'invoice.pdf', 'application/pdf'),
|
||||
).rejects.toMatchObject({ code: '42501' })
|
||||
// The moved object is taken back out: no row, no orphan.
|
||||
expect(remove).toHaveBeenCalledWith([buildReservedDocumentStoragePath(company, user, uploadId, 'invoice.pdf')])
|
||||
})
|
||||
|
||||
it('codes a missing or expired reservation as DOCUMENT_UPLOAD_NOT_FOUND', async () => {
|
||||
results = [{ data: null, error: null }]
|
||||
serviceClientOverride = makeClient({
|
||||
download: vi.fn().mockResolvedValue({ data: null, error: { message: 'Object not found' } }),
|
||||
})
|
||||
|
||||
await expect(
|
||||
completePendingDocumentUpload(makeClient() as never, company, user, uploadId, 'invoice.pdf', 'application/pdf'),
|
||||
).rejects.toMatchObject({ code: 'DOCUMENT_UPLOAD_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('cleans only expired pending objects in a bounded company and user prefix', async () => {
|
||||
const now = Date.parse('2026-08-03T10:00:00.000Z')
|
||||
const remove = vi.fn().mockResolvedValue({ data: [], error: null })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { dbError } from '@/lib/errors/db-error'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { DocumentExtractionOwner } from '@/lib/events/types'
|
||||
import type { DocumentAttachment, DocumentUploadSource } from '@/types'
|
||||
@@ -464,10 +465,32 @@ export async function createPendingDocumentUpload(
|
||||
}
|
||||
|
||||
export interface CompletedPendingDocumentUpload {
|
||||
document: DocumentAttachment
|
||||
/** `deduplicated` is set only when the caller opted into content dedupe
|
||||
* and the company had already archived these exact bytes. */
|
||||
document: DocumentAttachment & { deduplicated?: boolean }
|
||||
buffer: ArrayBuffer
|
||||
}
|
||||
|
||||
export interface CompletePendingDocumentUploadOptions {
|
||||
extractionOwner?: DocumentExtractionOwner
|
||||
/**
|
||||
* Provenance stamped on the document row. Default 'api': the signed-URL
|
||||
* primitives were built for MCP agents. The browser direct-to-storage path
|
||||
* (files too large for a hosted function body) passes 'file_upload' so the
|
||||
* archive tells the same story as the multipart route it replaces.
|
||||
*/
|
||||
uploadSource?: Extract<DocumentUploadSource, 'api' | 'file_upload'>
|
||||
/**
|
||||
* Content dedupe, same contract as uploadDocument({ dedupeByContent }):
|
||||
* after hashing, a current-version document in the same company with the
|
||||
* same SHA-256 wins, the pending object is removed and the existing row is
|
||||
* returned with `deduplicated: true`. Opt-in (default false) because the
|
||||
* MCP tools key their idempotency on document id === upload id: a dedupe
|
||||
* hit would return a different id and trip their collision guard.
|
||||
*/
|
||||
dedupeByContent?: boolean
|
||||
}
|
||||
|
||||
async function findReservedDocument(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
@@ -495,16 +518,32 @@ function validateReservedDocumentMetadata(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each verdict carries a registry code (structured-errors.ts) so a REST
|
||||
* caller can answer with the right status and copy instead of a generic
|
||||
* failure. The magic-byte sentence is authored Swedish user copy naming the
|
||||
* expected and detected types: it rides along as `messageSv` so the route
|
||||
* can show it without forwarding a raw error message.
|
||||
*/
|
||||
async function validatePendingDocumentBytes(
|
||||
buffer: ArrayBuffer,
|
||||
mimeType: string
|
||||
): Promise<string> {
|
||||
if (buffer.byteLength === 0) throw new Error('Uploaded file is empty')
|
||||
if (buffer.byteLength === 0) {
|
||||
throw Object.assign(new Error('Uploaded file is empty'), { code: 'DOC_UPLOAD_EMPTY' })
|
||||
}
|
||||
if (buffer.byteLength > MAX_DOCUMENT_SIZE) {
|
||||
throw new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`)
|
||||
throw Object.assign(new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`), {
|
||||
code: 'DOC_UPLOAD_TOO_LARGE',
|
||||
})
|
||||
}
|
||||
const magicError = validateDocumentMagicBytes(buffer, mimeType)
|
||||
if (magicError) throw new Error(magicError)
|
||||
if (magicError) {
|
||||
throw Object.assign(new Error(magicError), {
|
||||
code: 'DOC_UPLOAD_INVALID_CONTENT',
|
||||
messageSv: magicError,
|
||||
})
|
||||
}
|
||||
return computeSHA256(buffer)
|
||||
}
|
||||
|
||||
@@ -521,7 +560,7 @@ export async function completePendingDocumentUpload(
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
now: number = Date.now(),
|
||||
options: { extractionOwner?: DocumentExtractionOwner } = {}
|
||||
options: CompletePendingDocumentUploadOptions = {}
|
||||
): Promise<CompletedPendingDocumentUpload> {
|
||||
const serviceClient = createServiceClientNoCookies()
|
||||
const storage = serviceClient.storage.from(DOCUMENTS_BUCKET)
|
||||
@@ -552,7 +591,12 @@ export async function completePendingDocumentUpload(
|
||||
sourcePath = permanentPath
|
||||
}
|
||||
if (downloadError || !blob) {
|
||||
throw new Error('Document upload was not found or has expired. Create a new upload URL and try again.')
|
||||
// Coded so REST callers can answer 404 with the registry copy: the
|
||||
// browser PUT never landed, or the reservation outlived its TTL.
|
||||
throw Object.assign(
|
||||
new Error('Document upload was not found or has expired. Create a new upload URL and try again.'),
|
||||
{ code: 'DOCUMENT_UPLOAD_NOT_FOUND' },
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = await blob.arrayBuffer()
|
||||
@@ -564,6 +608,25 @@ export async function completePendingDocumentUpload(
|
||||
throw error
|
||||
}
|
||||
|
||||
if (options.dedupeByContent) {
|
||||
// Same lookup as uploadDocument: oldest current-version match wins, and
|
||||
// a broken lookup fails closed rather than archiving the duplicate.
|
||||
const { data: existingByContent, error: dedupeError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('sha256_hash', sha256Hash)
|
||||
.eq('is_current_version', true)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
if (dedupeError) throw dbError(dedupeError, 'Content dedupe lookup failed')
|
||||
const hit = (existingByContent as DocumentAttachment[] | null)?.[0]
|
||||
if (hit) {
|
||||
await storage.remove([sourcePath])
|
||||
return { document: { ...hit, deduplicated: true }, buffer }
|
||||
}
|
||||
}
|
||||
|
||||
if (sourcePath === pendingPath) {
|
||||
const { error: moveError } = await storage.move(pendingPath, permanentPath)
|
||||
if (moveError) {
|
||||
@@ -588,7 +651,7 @@ export async function completePendingDocumentUpload(
|
||||
version: 1,
|
||||
is_current_version: true,
|
||||
uploaded_by: userId,
|
||||
upload_source: 'api',
|
||||
upload_source: options.uploadSource ?? 'api',
|
||||
digitization_date: new Date(now).toISOString(),
|
||||
journal_entry_id: null,
|
||||
journal_entry_line_id: null,
|
||||
@@ -606,7 +669,12 @@ export async function completePendingDocumentUpload(
|
||||
return { document: concurrent, buffer }
|
||||
}
|
||||
await storage.remove([permanentPath])
|
||||
throw new Error(`Failed to create document record: ${error.message}`)
|
||||
// dbError keeps the SQLSTATE on the thrown error: a viewer-role member
|
||||
// passes the storage policy (membership only) but not the
|
||||
// document_attachments insert policy (writers only), and 42501 is what
|
||||
// lets the caller answer "no permission" in Swedish instead of a generic
|
||||
// failure.
|
||||
throw dbError(error, 'Failed to create document record')
|
||||
}
|
||||
|
||||
const document = data as DocumentAttachment
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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.'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2286,6 +2286,20 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const DOCUMENT: Record<string, StructuredErrorEntry> = {
|
||||
// Signed-URL (direct-to-storage) upload: completion found no object under
|
||||
// the reservation. The bytes never landed, or the reservation expired.
|
||||
DOCUMENT_UPLOAD_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Den uppladdade filen hittades inte eller har gått ut. Ladda upp filen igen.',
|
||||
message_en: 'The uploaded file was not found or the upload has expired. Upload the file again.',
|
||||
},
|
||||
// Signed-URL upload completed against an empty object: the PUT sent no
|
||||
// bytes, or sent them somewhere else.
|
||||
DOC_UPLOAD_EMPTY: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Filen är tom. Ladda upp filen igen.',
|
||||
message_en: 'The uploaded file is empty. Upload the file again.',
|
||||
},
|
||||
DOC_UPLOAD_NO_FILE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Ingen fil bifogad.',
|
||||
@@ -2394,6 +2408,13 @@ const INBOX_UPLOAD: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Uppladdningen misslyckades. Försök igen.',
|
||||
message_en: 'Upload failed.',
|
||||
},
|
||||
// A read-only (viewer) member: the storage policy admits the bytes on
|
||||
// membership alone, the document_attachments insert policy does not.
|
||||
INBOX_UPLOAD_NOT_PERMITTED: {
|
||||
httpStatus: 403,
|
||||
message_sv: 'Du har inte behörighet att ladda upp underlag i det här företaget. Medlemmar med läsbehörighet kan inte lägga till dokument.',
|
||||
message_en: 'You do not have permission to upload documents to this company. Read-only members cannot add documents.',
|
||||
},
|
||||
INBOX_ATTACH_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Bilagan kunde inte kopplas. Försök igen.',
|
||||
|
||||
Reference in New Issue
Block a user