0626bb6326
Long-open tabs keep running the JS bundle they first loaded, so a shipped change can look "missing" (e.g. a new settings field appearing only after a full reload) until the whole app is reloaded. Add a small, unobtrusive prompt that detects a newer deploy and offers a one-click reload. - next.config: inline the deploy's commit SHA into the client bundle as NEXT_PUBLIC_BUILD_ID (empty in dev / self-hosted, which disables the check). - /api/version: public, no-store route returning the running deployment's SHA at request time. - DeployReloadPrompt: compares the two on load, on tab focus, and on a 30-min backstop; shows a bottom banner with "Ladda om" on mismatch. Mounted once in the root layout. No service worker, degrades to a no-op with no build id. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
7.0 KiB
TypeScript
183 lines
7.0 KiB
TypeScript
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { NextConfig } from "next";
|
|
import createNextIntlPlugin from "next-intl/plugin";
|
|
|
|
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
|
|
|
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
const isDev = process.env.NODE_ENV === "development";
|
|
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
|
|
|
|
// WebSocket origin for Supabase Realtime. Hosted projects are covered by the
|
|
// wss://*.supabase.co wildcard below, but a SELF-HOSTED Supabase URL is not:
|
|
// Realtime opens wss://<supabase-host>/realtime/v1/websocket, and WebKit
|
|
// throws synchronously on a CSP-blocked `new WebSocket()`, unmounting the
|
|
// dashboard into the error boundary (issue #893). The Docker image bakes the
|
|
// __NEXT_PUBLIC_SUPABASE_WS_URL__ sentinel at build time and
|
|
// docker-entrypoint.sh substitutes the real value at runtime (a build-time
|
|
// https-to-wss replace would only rewrite the sentinel); the fallback derives
|
|
// wss:/ws: from the https/http URL for non-Docker builds where the real URL
|
|
// is present at build time. Empty supabaseUrl stays empty, mirroring how
|
|
// ${supabaseUrl} is interpolated below (extra whitespace is valid in CSP).
|
|
const supabaseWsUrl =
|
|
process.env.NEXT_PUBLIC_SUPABASE_WS_URL ??
|
|
supabaseUrl.replace(/^http(s?):/, "ws$1:");
|
|
|
|
const cspDirectives = [
|
|
"default-src 'self'",
|
|
// Recapt: scoped to the two specific hosts the SDK actually contacts:
|
|
// `cdn.recapt.app` for the script bundle and `api.recapt.app` for
|
|
// ingestion. The previous wildcard (`https://*.recapt.app`) allowed
|
|
// exfiltration to any subdomain of recapt.app and is intentionally
|
|
// narrowed.
|
|
`connect-src 'self' ${supabaseUrl} ${supabaseWsUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com https://api.recapt.app https://cdn.recapt.app`,
|
|
`style-src 'self' 'unsafe-inline' https://*.enablebanking.com`,
|
|
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com https://cdn.recapt.app`,
|
|
"img-src 'self' data: blob: https:",
|
|
"font-src 'self'",
|
|
"worker-src 'self' blob:",
|
|
// object-src must explicitly allow blob:: Chrome's built-in PDF viewer
|
|
// renders inline PDFs via an internal <embed>, which falls under
|
|
// object-src. Without this, blob:-URL invoice previews (created via
|
|
// URL.createObjectURL on /api/invoices/preview-pdf responses) show
|
|
// "Det här innehållet har blockerats" in Chrome. Firefox uses PDF.js and
|
|
// Edge uses its own viewer, so neither hits this. See crbug.com/271452.
|
|
"object-src 'self' blob:",
|
|
`frame-src 'self' blob: ${supabaseUrl}`,
|
|
"frame-ancestors 'none'",
|
|
].join("; ");
|
|
|
|
const nextConfig: NextConfig = {
|
|
output: 'standalone',
|
|
// Build id inlined into the client bundle so a running tab can tell when a
|
|
// newer deploy is live (see components/system/DeployReloadPrompt). On Vercel
|
|
// this is the commit SHA; empty elsewhere (dev / self-hosted), which disables
|
|
// the check. The /api/version route reads the same var at runtime to compare.
|
|
env: {
|
|
NEXT_PUBLIC_BUILD_ID: process.env.VERCEL_GIT_COMMIT_SHA ?? '',
|
|
},
|
|
// Multiple lockfiles exist above this project (e.g. a parent yarn.lock),
|
|
// which makes Turbopack infer the wrong workspace root. Pin it explicitly.
|
|
turbopack: {
|
|
root: projectRoot,
|
|
},
|
|
experimental: {
|
|
optimizePackageImports: ['recharts', 'date-fns', 'framer-motion'],
|
|
},
|
|
async redirects() {
|
|
return [
|
|
{
|
|
source: '/nyckeltal',
|
|
destination: '/kpi',
|
|
permanent: true,
|
|
},
|
|
// Docs canonicalised to docs.gnubok.se. Every `docs_url` field on the
|
|
// v1 error envelope still points at this host; the 308 forwards both
|
|
// humans and agents to the docs subdomain without us needing to
|
|
// mass-update structured-errors.
|
|
{
|
|
source: '/docs/api',
|
|
destination: 'https://docs.gnubok.se/',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/docs/api/:path*',
|
|
destination: 'https://docs.gnubok.se/:path*',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/llms-full.txt',
|
|
destination: 'https://docs.gnubok.se/llms-full.txt',
|
|
permanent: true,
|
|
},
|
|
]
|
|
},
|
|
async headers() {
|
|
// The catch-all excludes /api/documents/:id/inline so the strict
|
|
// X-Frame-Options: DENY + frame-ancestors 'none' don't conflict with
|
|
// the embeddable override below: Next.js applies every matching
|
|
// header rule, and duplicate X-Frame-Options/CSP values trigger
|
|
// "Det här innehållet har blockerats" in Chromium browsers.
|
|
return [
|
|
{
|
|
source: "/((?!api/documents/[^/]+/inline$).*)",
|
|
headers: [
|
|
{
|
|
key: "Strict-Transport-Security",
|
|
value: "max-age=63072000; includeSubDomains; preload",
|
|
},
|
|
{
|
|
key: "X-Frame-Options",
|
|
value: "DENY",
|
|
},
|
|
{
|
|
key: "X-Content-Type-Options",
|
|
value: "nosniff",
|
|
},
|
|
{
|
|
key: "Referrer-Policy",
|
|
value: "strict-origin-when-cross-origin",
|
|
},
|
|
{
|
|
key: "Permissions-Policy",
|
|
value: "camera=(), microphone=(), geolocation=(), payment=()",
|
|
},
|
|
{
|
|
key: "Content-Security-Policy",
|
|
value: cspDirectives,
|
|
},
|
|
],
|
|
},
|
|
// Document inline-preview proxy must be embeddable in same-origin
|
|
// iframes (used by the verifikat document preview Sheet). Excluded
|
|
// from the catch-all above so these values aren't shadowed by the
|
|
// stricter defaults.
|
|
//
|
|
// CSP is intentionally minimal: only `frame-ancestors 'self'`
|
|
// prevents cross-origin clickjacking on the user's documents.
|
|
// Adding `object-src 'none'` (or `default-src 'none'`) here breaks
|
|
// Chrome's built-in PDF viewer: Chrome renders inline PDFs through
|
|
// an internal <embed>, which the directive forbids, surfacing as
|
|
// "Det här innehållet har blockerats" in the document preview Sheet.
|
|
// Firefox uses PDF.js and Edge uses its own viewer, so neither hits
|
|
// this. See crbug.com/271452. X-Content-Type-Options: nosniff plus
|
|
// the explicit Content-Type from the route handler already prevent
|
|
// MIME-confusion abuse.
|
|
{
|
|
source: "/api/documents/:id/inline",
|
|
headers: [
|
|
{
|
|
key: "Strict-Transport-Security",
|
|
value: "max-age=63072000; includeSubDomains; preload",
|
|
},
|
|
{
|
|
key: "X-Frame-Options",
|
|
value: "SAMEORIGIN",
|
|
},
|
|
{
|
|
key: "X-Content-Type-Options",
|
|
value: "nosniff",
|
|
},
|
|
{
|
|
key: "Referrer-Policy",
|
|
value: "strict-origin-when-cross-origin",
|
|
},
|
|
{
|
|
key: "Permissions-Policy",
|
|
value: "camera=(), microphone=(), geolocation=(), payment=()",
|
|
},
|
|
{
|
|
key: "Content-Security-Policy",
|
|
value: "frame-ancestors 'self'",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
},
|
|
};
|
|
|
|
export default withNextIntl(nextConfig);
|