Bar items added by barcode stored the Open Food Facts image URL directly. The CSP in next.config.mjs restricts img-src to our own origin, so the browser blocked those and showed a broken image - the picture was fine, we just could not display it. Copy externally-hosted images into MinIO at lookup time and hand back a /minio-images path instead. That fixes the class rather than the instance: no CSP entry is needed per image source, the picture survives the source deleting or reorganising it, and the user's browser never has to talk to a third party to render their own bar. Also fixes a latent bug this uncovered: imageUrl was validated with z.string().url(), which rejects the relative /minio-images/... paths that uploadImage returns, so saving an uploaded drink image would fail validation. That matches production having zero drinks with an image. Both schemas now accept either form. CSP keeps two third-party entries for OAuth avatars, which the provider hosts and we only ever receive as a URL at sign-in. Existing rows were backfilled separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
91 lines
2.6 KiB
JavaScript
91 lines
2.6 KiB
JavaScript
/** @type {import('next').NextConfig} */
|
|
const nextConfig = {
|
|
output: "standalone",
|
|
images: {
|
|
remotePatterns: [
|
|
{
|
|
protocol: "http",
|
|
hostname: "localhost",
|
|
port: "9000",
|
|
pathname: "/drink-images/**",
|
|
},
|
|
{
|
|
protocol: "https",
|
|
hostname: "*.amazonaws.com",
|
|
pathname: "/**",
|
|
},
|
|
],
|
|
},
|
|
async rewrites() {
|
|
// Proxy image requests to MinIO so URLs work from any device
|
|
const minioHost = process.env.MINIO_ENDPOINT || "localhost";
|
|
const minioPort = process.env.MINIO_PORT || "9000";
|
|
const minioBucket = process.env.MINIO_BUCKET || "drink-images";
|
|
return [
|
|
{
|
|
source: "/minio-images/:path*",
|
|
destination: `http://${minioHost}:${minioPort}/${minioBucket}/:path*`,
|
|
},
|
|
];
|
|
},
|
|
async headers() {
|
|
return [
|
|
{
|
|
source: "/(.*)",
|
|
headers: [
|
|
{
|
|
key: "X-Frame-Options",
|
|
value: "DENY",
|
|
},
|
|
{
|
|
key: "X-Content-Type-Options",
|
|
value: "nosniff",
|
|
},
|
|
{
|
|
key: "Referrer-Policy",
|
|
value: "strict-origin-when-cross-origin",
|
|
},
|
|
{
|
|
key: "X-DNS-Prefetch-Control",
|
|
value: "on",
|
|
},
|
|
{
|
|
key: "Strict-Transport-Security",
|
|
value: "max-age=63072000; includeSubDomains; preload",
|
|
},
|
|
{
|
|
key: "Permissions-Policy",
|
|
value: "camera=(self), microphone=(), geolocation=(), interest-cohort=()",
|
|
},
|
|
{
|
|
key: "Content-Security-Policy",
|
|
value: [
|
|
"default-src 'self'",
|
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
// Product and drink images are mirrored into our own storage and served
|
|
// from 'self' via /minio-images, so third-party image hosts do not belong
|
|
// here. The exceptions are OAuth avatars, which the provider hosts and we
|
|
// only ever receive as a URL at sign-in.
|
|
[
|
|
"img-src 'self' data: blob:",
|
|
"http://localhost:9000",
|
|
"https://*.amazonaws.com",
|
|
"https://lh3.googleusercontent.com",
|
|
"https://avatars.githubusercontent.com",
|
|
].join(" "),
|
|
"font-src 'self'",
|
|
"connect-src 'self' http://localhost:9000",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
].join("; "),
|
|
},
|
|
],
|
|
},
|
|
];
|
|
},
|
|
};
|
|
|
|
export default nextConfig;
|