fix(underlag): carry the phone photo that is too big to send, and say why when we cannot (#1550)
* fix(whatsapp-inbox): register the channel question event types Every follow-up question the WhatsApp intake asks has been failing its processing_history append in production: ChannelQuestionAsked, ChannelQuestionAnswered and ChannelQuestionExpired were never added to the processing_event_types catalog the event_type FK points at. appendQuestionHistory() catches and logs that failure by design, so the reply to the sender still goes out and nothing looked broken from the outside. What was lost is the durable record of the exchange, which is part of how the underlag was obtained (BFNAR 2013:2 kap 8). Catalog rows only: aggregate_type 'System' already passes the CHECK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(underlag): say why an upload failed, and get out of an expired session A user reported that none of the three ways to add a receipt from a phone worked, all of them answering "Uppladdning misslyckades. Nagot gick fel, forsok igen" immediately. Production told us nothing: every upload request that reached the route in the same 24 hours returned 200. Both halves of that are the same bug. The workspace read failures as `throw new Error(json.error)`, which loses a body that is not JSON (the res.json() call throws first) and stringifies the structured envelope to "[object Object]", so anything the route did not answer with a plain string arrived as the generic fallback. The middleware 401 for an expired cookie session is exactly that envelope shape, and a phone tab left open is exactly where the session expires unnoticed: the controller's timers are throttled in the background, so the request the user just made is what finds out. Now the response is resolved where it fails, through the house helper that already knows the status map, and an expired session is announced on the session-timeout BroadcastChannel so the controller signs out and routes to /login the same way it does for an expired heartbeat. Failed uploads also post metadata (status, size, mime type, resolved reason) to /api/log, the one API path exempt from the timeout gate, so a request answered before the route runs stops being invisible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(underlag): carry the phone photo that is too big to send The reported failure was not the account and not the session: hosted rejects any request body over 4.5 MB itself, before the function runs. Measured against production, 4.4 MB reaches the route and 4.6 MB comes back as a plain-text FUNCTION_PAYLOAD_TOO_LARGE. Nothing invokes the function, so nothing lands in the logs, which is why one user's failing uploads were invisible while every upload that arrived returned 200. An iPhone photo in "Most Compatible" mode is 4-12 MB, so whether it worked depended on whose phone took the picture. Meanwhile the route advertises a 10 MB limit it can never be handed. Photos are now re-encoded in the browser when they exceed what the platform will carry: 2400px on the long edge at JPEG q0.85, stepping the quality down only if that is not enough. That keeps the small print on a receipt legible, which is what BFL 7 kap asks of an archived underlag ("varaktigt läsbart skick", a faithful reproduction), and a refusal is not. What cannot be shrunk (a PDF, or HEIC where the browser will not decode it) is refused before the upload starts, naming its actual size and the limit rather than failing in transit. 413 joins the HTTP status map so a rejection we cannot pre-empt still says what happened: the platform's body is plain text, so the status is the only thing there is to translate. Self-hosted Docker has no proxy in front of the app, so none of this applies there and the route's own MAX_FILE_SIZE keeps governing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
98612fb0ac
commit
38a890c8d1
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
notifySessionExpired,
|
||||
SESSION_TIMEOUT_CHANNEL,
|
||||
SESSION_TIMEOUT_REASON_HEADER,
|
||||
} from '../session-timeout-shared'
|
||||
|
||||
const posted: Array<{ name: string; message: unknown }> = []
|
||||
|
||||
class FakeBroadcastChannel {
|
||||
constructor(public readonly name: string) {}
|
||||
postMessage(message: unknown) {
|
||||
posted.push({ name: this.name, message })
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
|
||||
function expiredResponse(reason: string, status = 401): Response {
|
||||
return new Response(
|
||||
JSON.stringify({ error: { code: 'SESSION_EXPIRED', message: 'Sessionen har upphört.' } }),
|
||||
{ status, headers: { [SESSION_TIMEOUT_REASON_HEADER]: reason } },
|
||||
)
|
||||
}
|
||||
|
||||
describe('notifySessionExpired', () => {
|
||||
beforeEach(() => {
|
||||
posted.length = 0
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('announces an idle timeout on the session channel', () => {
|
||||
expect(notifySessionExpired(expiredResponse('idle'))).toBe(true)
|
||||
expect(posted).toEqual([
|
||||
{ name: SESSION_TIMEOUT_CHANNEL, message: { type: 'expired', reason: 'idle' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('announces an absolute timeout on the session channel', () => {
|
||||
expect(notifySessionExpired(expiredResponse('absolute'))).toBe(true)
|
||||
expect(posted).toEqual([
|
||||
{ name: SESSION_TIMEOUT_CHANNEL, message: { type: 'expired', reason: 'absolute' } },
|
||||
])
|
||||
})
|
||||
|
||||
// A 401 from a route's own auth check is not a timeout: signing the user out
|
||||
// and redirecting on one would turn a single failed call into a logout.
|
||||
it('ignores a 401 that carries no timeout reason', () => {
|
||||
expect(notifySessionExpired(new Response('{}', { status: 401 }))).toBe(false)
|
||||
expect(posted).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores an unknown reason', () => {
|
||||
expect(notifySessionExpired(expiredResponse('whatever'))).toBe(false)
|
||||
expect(posted).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a successful response that happens to carry the header', () => {
|
||||
expect(notifySessionExpired(expiredResponse('idle', 200))).toBe(false)
|
||||
expect(posted).toEqual([])
|
||||
})
|
||||
|
||||
it('still reports the timeout where BroadcastChannel is unavailable', () => {
|
||||
vi.stubGlobal('BroadcastChannel', undefined)
|
||||
expect(notifySessionExpired(expiredResponse('idle'))).toBe(true)
|
||||
expect(posted).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
export const SESSION_TIMEOUT_COOKIE = 'gnubok-session-timeout'
|
||||
export const SESSION_AUTH_METHOD_HINT_COOKIE = 'gnubok-auth-method'
|
||||
export const SESSION_TIMEOUT_CHANNEL = 'gnubok-session-timeout'
|
||||
/** Set by middleware on the 401 it answers an expired cookie session with. */
|
||||
export const SESSION_TIMEOUT_REASON_HEADER = 'x-session-timeout-reason'
|
||||
|
||||
export type SessionAuthMethod = 'password' | 'bankid'
|
||||
export type SessionTimeoutReason = 'idle' | 'absolute'
|
||||
@@ -20,6 +22,34 @@ export function isSessionAuthMethod(value: unknown): value is SessionAuthMethod
|
||||
return value === 'password' || value === 'bankid'
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce an expired session that a plain data request ran into, so the
|
||||
* SessionTimeoutController signs out and routes to /login exactly as it does
|
||||
* for an expired heartbeat.
|
||||
*
|
||||
* The controller's own timers are the normal detector, but a backgrounded
|
||||
* mobile tab has them throttled: the first thing that notices is often the
|
||||
* request the user just made. Without this the page stays up while every
|
||||
* action fails, and the failure surfaces as a message about that action
|
||||
* (an upload that "misslyckades") rather than as the re-login it really is.
|
||||
*
|
||||
* Returns whether the response was in fact a session timeout, so callers can
|
||||
* skip their own error toast when the redirect is already on its way.
|
||||
*/
|
||||
export function notifySessionExpired(response: Response): boolean {
|
||||
const raw = response.status === 401
|
||||
? response.headers.get(SESSION_TIMEOUT_REASON_HEADER)
|
||||
: null
|
||||
if (raw !== 'idle' && raw !== 'absolute') return false
|
||||
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
const channel = new BroadcastChannel(SESSION_TIMEOUT_CHANNEL)
|
||||
channel.postMessage({ type: 'expired', reason: raw })
|
||||
channel.close()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function setSessionAuthMethodHint(method: SessionAuthMethod): void {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import {
|
||||
HOSTED_MAX_UPLOAD_BYTES,
|
||||
HOSTED_REQUEST_BODY_LIMIT_BYTES,
|
||||
exceedsHostedUploadLimit,
|
||||
formatMegabytes,
|
||||
isShrinkableImage,
|
||||
tooLargeMessage,
|
||||
} from '../upload-size'
|
||||
import { shrinkImageForUpload } from '../shrink-image'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('upload size limits', () => {
|
||||
it('stays under the platform ceiling so the multipart envelope fits', () => {
|
||||
expect(HOSTED_MAX_UPLOAD_BYTES).toBeLessThan(HOSTED_REQUEST_BODY_LIMIT_BYTES)
|
||||
})
|
||||
|
||||
it('flags a file over the limit on hosted', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
|
||||
expect(exceedsHostedUploadLimit(HOSTED_MAX_UPLOAD_BYTES + 1)).toBe(true)
|
||||
expect(exceedsHostedUploadLimit(HOSTED_MAX_UPLOAD_BYTES)).toBe(false)
|
||||
})
|
||||
|
||||
// Docker self-hosting has no proxy in front of the app, so the route's own
|
||||
// MAX_FILE_SIZE governs and nothing should be refused or re-encoded here.
|
||||
it('never flags a file on self-hosted', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
||||
expect(exceedsHostedUploadLimit(50 * 1024 * 1024)).toBe(false)
|
||||
})
|
||||
|
||||
it('recognises the image types a canvas can re-encode', () => {
|
||||
expect(isShrinkableImage('image/jpeg')).toBe(true)
|
||||
expect(isShrinkableImage('image/HEIC')).toBe(true)
|
||||
expect(isShrinkableImage('application/pdf')).toBe(false)
|
||||
expect(isShrinkableImage('')).toBe(false)
|
||||
expect(isShrinkableImage(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('names both the actual size and the ceiling', () => {
|
||||
const message = tooLargeMessage(6 * 1024 * 1024)
|
||||
expect(message).toContain('6,0 MB')
|
||||
expect(message).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
|
||||
}
|
||||
|
||||
it('returns a file that already fits untouched', async () => {
|
||||
const file = fakeFile(1024, 'image/jpeg')
|
||||
expect(await shrinkImageForUpload(file)).toBe(file)
|
||||
})
|
||||
|
||||
it('returns a PDF untouched: there is nothing a canvas can do with it', async () => {
|
||||
const file = fakeFile(9 * 1024 * 1024, 'application/pdf')
|
||||
expect(await shrinkImageForUpload(file)).toBe(file)
|
||||
})
|
||||
|
||||
// No createImageBitmap outside a browser (and none for HEIC outside Safari):
|
||||
// the caller falls back to the size message rather than a silent failure.
|
||||
it('returns the original where the browser cannot decode it', async () => {
|
||||
const file = fakeFile(9 * 1024 * 1024, 'image/heic')
|
||||
expect(await shrinkImageForUpload(file)).toBe(file)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { HOSTED_MAX_UPLOAD_BYTES, isShrinkableImage } from './upload-size'
|
||||
|
||||
/**
|
||||
* Re-encode an oversized photo in the browser so it fits the platform's
|
||||
* request-body ceiling (see upload-size.ts for why that ceiling exists).
|
||||
*
|
||||
* Only reached for files that cannot be sent as they are. The archived
|
||||
* document is whatever comes back from here, so the goal is a faithful,
|
||||
* durably readable reproduction of the receipt (BFL 7 kap: "varaktigt läsbart
|
||||
* skick"), not the smallest possible file. 2400px on the long edge keeps the
|
||||
* small print on a receipt legible while taking a 12 MP phone photo well under
|
||||
* the limit; quality steps down only if that is not enough.
|
||||
*
|
||||
* Returns the original file untouched when the browser cannot decode it (HEIC
|
||||
* outside Safari, a corrupt image) or when re-encoding would not help. The
|
||||
* caller then reports the honest size message rather than uploading something
|
||||
* that is going to be rejected in transit.
|
||||
*/
|
||||
|
||||
const MAX_EDGE_PX = 2400
|
||||
const QUALITY_STEPS = [0.85, 0.7, 0.55]
|
||||
|
||||
export async function shrinkImageForUpload(
|
||||
file: File,
|
||||
maxBytes: number = HOSTED_MAX_UPLOAD_BYTES,
|
||||
): Promise<File> {
|
||||
if (file.size <= maxBytes) return file
|
||||
if (!isShrinkableImage(file.type)) return file
|
||||
if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') return file
|
||||
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const scale = Math.min(1, MAX_EDGE_PX / Math.max(bitmap.width, bitmap.height))
|
||||
const width = Math.max(1, Math.round(bitmap.width * scale))
|
||||
const height = Math.max(1, Math.round(bitmap.height * scale))
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) {
|
||||
bitmap.close()
|
||||
return file
|
||||
}
|
||||
context.drawImage(bitmap, 0, 0, width, height)
|
||||
bitmap.close()
|
||||
|
||||
for (const quality of QUALITY_STEPS) {
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/jpeg', quality)
|
||||
})
|
||||
if (!blob) return file
|
||||
if (blob.size <= maxBytes) {
|
||||
return new File([blob], jpegName(file.name), {
|
||||
type: 'image/jpeg',
|
||||
lastModified: file.lastModified,
|
||||
})
|
||||
}
|
||||
}
|
||||
return file
|
||||
} catch {
|
||||
// A decode the browser refuses (HEIC off Safari) is not an error worth
|
||||
// surfacing on its own: the size message the caller falls back to is the
|
||||
// one the user can act on.
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
function jpegName(name: string): string {
|
||||
const base = name.replace(/\.[^.]+$/, '') || 'underlag'
|
||||
return `${base}.jpg`
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* What the hosting platform will actually carry, as opposed to what the
|
||||
* upload routes say they accept.
|
||||
*
|
||||
* Vercel rejects a request body over 4.5 MB itself, before the function runs:
|
||||
* the caller gets a plain-text 413 (FUNCTION_PAYLOAD_TOO_LARGE) and nothing
|
||||
* reaches the route, so nothing lands in the function logs either. That is why
|
||||
* a user reporting "uploading from my phone just fails" was invisible in
|
||||
* production while every upload that did arrive returned 200. A phone photo
|
||||
* routinely exceeds it: iPhone JPEG capture ("Most Compatible") lands at
|
||||
* 4-12 MB, well over the route's own 10 MB promise that can never be reached
|
||||
* on hosted.
|
||||
*
|
||||
* Self-hosted Docker has no such proxy limit, so the route's own MAX_FILE_SIZE
|
||||
* governs there and none of this applies.
|
||||
*/
|
||||
|
||||
/** The platform's hard ceiling on a request body. */
|
||||
export const HOSTED_REQUEST_BODY_LIMIT_BYTES = Math.round(4.5 * 1024 * 1024)
|
||||
|
||||
/**
|
||||
* The largest file we will put in a multipart body. Below the hard ceiling by
|
||||
* enough to cover the multipart envelope (boundaries, part headers, the file
|
||||
* name) so a file that just fits does not fail on the framing around it.
|
||||
*/
|
||||
export const HOSTED_MAX_UPLOAD_BYTES = 4 * 1024 * 1024
|
||||
|
||||
export function isHostedDeployment(): boolean {
|
||||
return process.env.NEXT_PUBLIC_SELF_HOSTED !== 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* Image types a browser canvas can decode and re-encode. HEIC/HEIF are
|
||||
* included deliberately: Safari on iOS decodes them natively, and iOS is
|
||||
* exactly where the oversized photos come from. Elsewhere the decode throws
|
||||
* and the caller keeps the original, which then gets the honest size message
|
||||
* instead of a silent failure.
|
||||
*/
|
||||
const SHRINKABLE_IMAGE_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'image/webp',
|
||||
])
|
||||
|
||||
export function isShrinkableImage(type: string | null | undefined): boolean {
|
||||
return SHRINKABLE_IMAGE_TYPES.has(String(type ?? '').toLowerCase())
|
||||
}
|
||||
|
||||
/** True when this file cannot be sent as-is on a hosted deployment. */
|
||||
export function exceedsHostedUploadLimit(size: number): boolean {
|
||||
return isHostedDeployment() && size > HOSTED_MAX_UPLOAD_BYTES
|
||||
}
|
||||
|
||||
export function formatMegabytes(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence for a file that is over the limit and cannot be shrunk (a PDF,
|
||||
* 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.
|
||||
*/
|
||||
export function tooLargeMessage(size: number): string {
|
||||
return (
|
||||
`Filen är ${formatMegabytes(size)} och gränsen är ${formatMegabytes(HOSTED_MAX_UPLOAD_BYTES)}. ` +
|
||||
'Fotografera om kvittot, eller komprimera PDF:en, och försök igen.'
|
||||
)
|
||||
}
|
||||
@@ -374,6 +374,17 @@ describe('getErrorMessage: API response body vs new Error(body.error)', () => {
|
||||
expect(msg).not.toBe('Något gick fel. Försök igen.')
|
||||
})
|
||||
|
||||
// A body the platform rejects never reaches a route, and the 413 it answers
|
||||
// with is plain text: the status is the only thing left to translate.
|
||||
it('a payload-too-large rejection says so, with no body to read', () => {
|
||||
expect(getErrorMessage(null, { statusCode: 413 })).toBe(
|
||||
'Filen är för stor för att skickas. Försök igen med en mindre fil.',
|
||||
)
|
||||
expect(getErrorMessage(null, { statusCode: 413, locale: 'en' })).toBe(
|
||||
'The file is too large to send. Try again with a smaller file.',
|
||||
)
|
||||
})
|
||||
|
||||
it('new Error(body.error) stringifies the envelope to "[object Object]"', () => {
|
||||
// The defect in one line: the Error constructor calls String() on the object.
|
||||
expect(new Error(envelope.error as unknown as string).message).toBe('[object Object]')
|
||||
|
||||
@@ -69,6 +69,10 @@ const HTTP_STATUS_MAP: Record<number, Bilingual> = {
|
||||
403: { sv: 'Du har inte behörighet att utföra denna åtgärd.', en: 'You do not have permission to perform this action.' },
|
||||
404: { sv: 'Resursen kunde inte hittas.', en: 'The resource could not be found.' },
|
||||
409: { sv: 'En konflikt uppstod. Ladda om sidan och försök igen.', en: 'A conflict occurred. Reload the page and try again.' },
|
||||
// 413 is answered by the hosting platform, before any route runs, with a
|
||||
// plain-text body: the status is the only thing a caller has to go on.
|
||||
413: { sv: 'Filen är för stor för att skickas. Försök igen med en mindre fil.', en: 'The file is too large to send. Try again with a smaller file.' },
|
||||
415: { sv: 'Filtypen stöds inte.', en: 'That file type is not supported.' },
|
||||
422: { sv: 'Uppgifterna kunde inte bearbetas. Kontrollera fälten och försök igen.', en: 'The data could not be processed. Check the fields and try again.' },
|
||||
429: { sv: 'För många förfrågningar. Vänta en stund och försök igen.', en: 'Too many requests. Wait a moment and try again.' },
|
||||
500: { sv: 'Ett oväntat serverfel uppstod. Försök igen senare.', en: 'An unexpected server error occurred. Please try again later.' },
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isSessionAuthMethod,
|
||||
SESSION_AUTH_METHOD_HINT_COOKIE,
|
||||
SESSION_TIMEOUT_COOKIE,
|
||||
SESSION_TIMEOUT_REASON_HEADER,
|
||||
type SessionAuthMethod,
|
||||
type SessionTimeoutReason,
|
||||
} from '@/lib/auth/session-timeout-shared'
|
||||
@@ -504,7 +505,7 @@ function sessionTimeoutResponse(
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
response.headers.set('X-Session-Timeout-Reason', reason)
|
||||
response.headers.set(SESSION_TIMEOUT_REASON_HEADER, reason)
|
||||
response.headers.set('Cache-Control', 'no-store')
|
||||
copyResponseCookies(authResponse, response)
|
||||
return response
|
||||
|
||||
Reference in New Issue
Block a user