f0e6d2dfb1
Harden gallery authentication and authorization, consolidate gallery workflows, and prevent token-bearing URLs from leaking through nginx request error logs.
335 lines
16 KiB
Nginx Configuration File
335 lines
16 KiB
Nginx Configuration File
# URLs and Referer can contain gallery, image and customer bearer tokens.
|
||
map $uri $request_surface {
|
||
~*^/api/(?<picpeak_surface>[a-z-]+)(?:/|$) /api/$picpeak_surface;
|
||
default /;
|
||
}
|
||
log_format picpeak_safe '$remote_addr "$http_x_forwarded_for" [$time_local] "$request_method $request_surface" '
|
||
'$status $body_bytes_sent $request_time';
|
||
|
||
# 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 {
|
||
access_log /var/log/nginx/access.log picpeak_safe;
|
||
# Native request errors include bearer URLs even at crit/alert severity.
|
||
# Safe access logs retain status/timing; process diagnostics stay at main level.
|
||
error_log /dev/null;
|
||
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;
|
||
}
|
||
|
||
# Analytics tracker proxy (backend). The tracker script is served from our
|
||
# own origin so `script-src 'self'` / `connect-src 'self'` above already
|
||
# cover it and no admin-configured tracker domain has to be added to the
|
||
# CSP by hand. It NEEDS its own block: the script URL ends in `.js`, and
|
||
# the `~* \.(js|css|…)$` regex location above outranks the plain `/api`
|
||
# prefix in nginx's matching order, so `/api/analytics/tracker/script.js`
|
||
# would otherwise be looked up as a static file and 404. `^~` stops regex
|
||
# evaluation for this prefix. Keep in sync with the mount path in
|
||
# backend/server.js.
|
||
location ^~ /api/analytics/tracker/ {
|
||
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;
|
||
proxy_read_timeout 30s;
|
||
client_max_body_size 64k;
|
||
}
|
||
|
||
# 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.
|
||
# {0,2} extra path segments so the deeper gallery share shapes match too:
|
||
# /gallery/<slug> (public / share-token link)
|
||
# /gallery/<slug>/client-access (client-access; token is in ?query)
|
||
# /gallery/<slug>/show/<token> (slideshow; token is a PATH segment)
|
||
# The slideshow shape has TWO extra segments (show + token) — the old
|
||
# single-segment `(?:/[^/]+)?` never matched it, so slideshow links fell
|
||
# through to the SPA and got only the generic site-wide OG (#699 follow-up).
|
||
# NB: the regex is QUOTED because the {0,2} quantifier's braces would
|
||
# otherwise be parsed as nginx config block delimiters.
|
||
location ~ "^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+){0,2}/?$" {
|
||
# 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.
|
||
# Viber + the broader set below added in #699 follow-up. Only
|
||
# CRAWLER-EXCLUSIVE tokens — the backend OG response is meta-only (no
|
||
# redirect), so UAs shared with real human in-app browsers (WeChat's
|
||
# MicroMessenger, LINE's "Line/", Zalo, "InAppBrowser") are NOT added.
|
||
if ($http_user_agent ~* "(facebookexternalhit|facebookcatalog|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|Cardyb|OpenGraph|LinkPreview|Viber|Signal|Misskey|Pleroma|Synapse|Nextcloud|Rocket\.Chat|kakaotalk-scrap|Google-PageRenderer|OdklBot|ZoomBot)") {
|
||
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;
|
||
}
|
||
|
||
# Branded URL shortener (#699). The backend `/s/<short_slug>` route both
|
||
# 302-redirects humans to the target gallery AND server-renders OG for
|
||
# social crawlers. Without this proxy, `/s/...` fell through to the SPA
|
||
# (which has no /s/ route) — so branded short links were dead for humans
|
||
# and crawlers alike. `^~` beats the regex SPA fallback below. The backend
|
||
# does its own UA detection, so no crawler `if` is needed here.
|
||
location ^~ /s/ {
|
||
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;
|
||
}
|
||
}
|