5488de3383
When PicPeak runs behind NPM / Traefik / Caddy, the inner nginx receives plain HTTP from the outer proxy. The previous `X-Forwarded-Proto $scheme` therefore always forwarded "http" to the backend, even when the public URL was HTTPS. Express has `trust proxy` enabled for loopback/linklocal, so req.secure became false, the Secure cookie flag wasn't set, and generated URLs (cookies, tokens) used http://. Add a top-of-file `map` block that picks the incoming X-Forwarded-Proto when present and falls back to `$scheme` for direct access. Applied to both nginx.conf (bundled production image) and nginx.dev.conf. Validated with `nginx -t` against nginx:1.28-alpine (the same image used by Dockerfile.prod / Dockerfile).
230 lines
10 KiB
Nginx Configuration File
230 lines
10 KiB
Nginx Configuration File
# 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;
|
|
|
|
# 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;
|
|
}
|
|
|
|
# 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;
|
|
}
|
|
}
|