f101bde6a8
The image is built once with sentinel values
(ENV NEXT_PUBLIC_SELF_HOSTED=__NEXT_PUBLIC_SELF_HOSTED__) that
docker-entrypoint.sh seds into .next at container start. Comparing a flag in
place defeats that: the bundler inlines the sentinel, the minifier folds
"__NEXT_PUBLIC_SELF_HOSTED__" === 'true' to false and eliminates the branch, so
both the variable name and the sentinel disappear and sed has nothing left to
replace. The flag is then permanently false whatever the operator configures.
Diagnosed against a running self-hosted instance: the compiled gate read
function r(){return"true"!==process.env.FORCE_PAYWALL
&&"true"===process.env.DISABLE_PAYWALL}
with the isSelfHosted() branch gone. The un-prefixed FORCE_PAYWALL /
DISABLE_PAYWALL survived precisely because they are never inlined, and
NODE_ENV === 'development' was folded away by the same mechanism. The one
place the flag still worked, getSessionTimeoutConfig(env = process.env), reads
it off a parameter the bundler cannot fold.
Consequence: every Docker self-host ran with the entitlement paywall live, so
ai, bank_sync, skatteverket and email_send went dark 30 days after company
creation when the seeded trial grants expired. Nothing surfaced it, because
dev and the Vercel build both have real env values and never reproduce it.
Analytics, forced MFA, BankID and the hosted upload ceiling read the same flag
and were wrong in the same direction.
Flags are now read as values through lib/env/public-flags, which keeps the
sentinel in the output as a live string literal and defers the comparison to
runtime. flagEnabled uses a Set lookup rather than ===, which a minifier could
fold if it ever inlined the helper.
Guarded twice, because the source fix alone would not have caught this:
- check:guards folded-public-flag fails any in-place NEXT_PUBLIC_* comparison
(AST, no baseline, verified to fire on a probe file);
- docker-publish asserts the sentinels survive the built image, which is the
only artifact where the failure is observable.
npm test 14999 passed, npm run lint 0 errors, npm run check:guards clean.
Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
75 lines
2.8 KiB
TypeScript
75 lines
2.8 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
|
|
import { isSelfHosted } from '@/lib/env/public-flags'
|
|
|
|
/** 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 !isSelfHosted()
|
|
}
|
|
|
|
/**
|
|
* 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.'
|
|
)
|
|
}
|