Files
picpeak/frontend/nginx.conf
T
Luca db3e3270f3 fix(branding): serve favicon via backend route so Safari picks it up
Safari requests /favicon.ico and /apple-touch-icon*.png at the site root and
is unreliable about honouring JS-injected <link rel=icon>, so an admin-set
favicon never showed there (index.html only ships /favicon-32x32.png; a bare
/favicon.ico 404'd).

- Backend: GET /favicon.ico + /apple-touch-icon(.png|-precomposed.png) resolve
  the configured branding_favicon_url (redirect to its /uploads path or the
  absolute URL), falling back to the bundled /favicon-32x32.png.
- nginx: exact-match (=) locations proxy those paths to the backend, winning
  over the static-asset regex that previously served them from the build dir.
- DynamicFavicon also emits an apple-touch-icon link (belt-and-braces).

Requires a frontend image REBUILD (nginx.conf change) in addition to backend.
2026-06-03 17:35:32 +02:00

273 lines
12 KiB
Nginx Configuration File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Honour the outer reverse proxy's X-Forwarded-Proto when present (e.g. NPM,
# Traefik, Caddy in front of PicPeak). Falls back to nginx's own $scheme when
# the header is absent (direct access / no outer proxy). Without this the
# inner nginx was always forwarding "http" to the backend because the outer
# proxy → inner nginx hop is plain HTTP, breaking Secure cookies and HTTPS
# URL generation in the backend. See issue #547.
map $http_x_forwarded_proto $real_proto {
default $http_x_forwarded_proto;
"" $scheme;
}
server {
listen 80;
server_name localhost;
server_tokens off;
root /usr/share/nginx/html;
index index.html;
# Docker DNS resolver for dynamic service discovery (required for Swarm/Compose)
resolver 127.0.0.11 valid=10s ipv6=off;
resolver_timeout 5s;
# Allow larger file uploads (up to 1GB for video support)
client_max_body_size 1G;
client_body_timeout 300s;
# Defensive header buffer bump (#591). Default `4 8k` is too tight when
# an outer Cloudflare / corp-proxy sits in front and injects long
# Set-Cookie / X-Forwarded-* headers, or when a power-user accumulates
# many per-gallery `gallery_token_<slug>` cookies over the 24h maxAge
# in tokenUtils.js. Either way users hit "400 Request Header Or Cookie
# Too Large" and clearing cookies is the only fix. 4×32k is cheap RAM
# and matches what most reverse proxies already do upstream.
large_client_header_buffers 4 32k;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
# Strip the same headers when emitted by the upstream backend so nginx
# is the single source. Without this, helmet (in the Express app) and
# nginx both emit the headers and clients see duplicates — testssl
# flagged "Multiple X-Frame-Options / X-Content-Type-Options / CSP /
# Permissions-Policy / Referrer-Policy headers" on the live origin.
# proxy_hide_header at server level applies to every proxy_pass below.
proxy_hide_header X-Frame-Options;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header Referrer-Policy;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Permissions-Policy;
proxy_hide_header Strict-Transport-Security;
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
# Re-apply security headers (add_header in location block overrides server-level)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
}
# Cache index.html with revalidation
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
# Re-apply security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
}
# API proxy
location /api {
# Use variable to force DNS resolution per request (required for Docker Swarm)
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
# Allow larger uploads for API endpoints (up to 1GB for video support)
client_max_body_size 1G;
client_body_timeout 300s;
}
# Photo serving proxy
location /photos {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
# Cache photos
proxy_cache_valid 200 302 1d;
proxy_cache_valid 404 1m;
}
# Thumbnail serving proxy
location /thumbnails {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
# Cache thumbnails
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
}
# Uploads serving proxy (logos, favicons, watermarks)
# ^~ modifier stops regex matching, ensuring uploads are proxied not served locally
location ^~ /uploads {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
# Cache uploads
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
}
# Self-hosted webfonts proxy (bundled families + admin user additions).
# ^~ modifier stops regex matching, ensuring fonts are proxied to the
# backend (which scans backend/assets/fonts and STORAGE_PATH/fonts) and
# NOT served locally — the .woff2 files do not exist in the frontend image.
location ^~ /fonts {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
# Fonts rarely change; cache aggressively (matches backend Cache-Control).
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
}
# Dynamic robots.txt served by backend
location = /robots.txt {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000/robots.txt;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
}
# Dynamic favicon / apple-touch-icon served by backend (resolves the
# admin-configured branding favicon, falls back to the bundled asset).
# Exact-match (=) wins over the static-asset regex below, so these reach
# the backend instead of the build dir. Browsers (especially Safari)
# request these at the site root regardless of any JS-injected
# <link rel="icon">.
location = /favicon.ico {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000/favicon.ico;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
}
location = /apple-touch-icon.png {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000/apple-touch-icon.png;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
}
location = /apple-touch-icon-precomposed.png {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000/apple-touch-icon-precomposed.png;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
}
# Delegate root requests to backend for public landing page handling
location = / {
# Use variable to force DNS resolution per request (required for Docker Swarm)
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
proxy_read_timeout 60s;
}
# Social-crawler detection for gallery share URLs. Crawlers (WhatsApp,
# Facebook, Slack, Twitter, etc.) don't run JS, so the SPA's client-side
# meta tags never reach them. Route those UAs to backend's /og handler
# via internal rewrite; humans fall through to the SPA via try_files.
location ~ ^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ {
# Keep this list in sync with SOCIAL_CRAWLER_PATTERNS in
# backend/src/services/galleryOgService.js. WhatsAppBot / wa-bot
# and LinkPreview / Slack-ImgProxy added in #521 to catch
# business-API preview fetchers that aren't the main WhatsApp app.
if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph|LinkPreview)") {
rewrite ^ /og/gallery/$gallery_slug last;
}
try_files $uri $uri/ /index.html;
}
# OG preview endpoint (proxied to backend). Public endpoint by design —
# only exposes event_name + branding logo, no protected photo content.
location ^~ /og/gallery/ {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Deny access to hidden files
location ~ /\. {
deny all;
}
}