diff --git a/README.md b/README.md index f6857bc..020b6f5 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,10 @@ the VPS rsync target consumed by `make deploy`. Never commit it. - `tools/` — Python tooling (embeddings, importers) and shell scripts. - `data/` — generated and source data (commonplace.yaml, annotations.json, bibliographies, similar-links.json). -- `paper/` — LaTeX source for in-progress academic papers. -- `spec.md` — full architectural notes and design intent. +- `nginx/` — vhost snippets shipped to the VPS (`security-headers.conf`, + `static-assets.conf`, `popup-proxy.conf`). The live vhost on the VPS + is the source of truth; see `nginx/vhost.conf.example` for the + canonical structure and the include order these snippets expect. ## Architecture pointers @@ -79,8 +81,6 @@ the VPS rsync target consumed by `make deploy`. Never commit it. - `build/Filters/Images.hs` does WebP `` wrapping; requires the `.webp` companions produced by `tools/convert-images.sh`. -For deeper architectural detail, see `spec.md`. - ## License See `LICENSE`. diff --git a/nginx/security-headers.conf b/nginx/security-headers.conf new file mode 100644 index 0000000..e1c3b5b --- /dev/null +++ b/nginx/security-headers.conf @@ -0,0 +1,70 @@ +# security-headers.conf — security baseline for the levineuwirth.org vhost. +# +# Place at /etc/nginx/snippets/security-headers.conf and `include` it +# inside the server { } block of the vhost, alongside the other +# snippets shipped from this repo: +# +# server { +# server_name levineuwirth.org; +# root /var/www/levineuwirth.org; +# ... +# include snippets/security-headers.conf; +# include snippets/static-assets.conf; +# include snippets/popup-proxy.conf; +# } + +# Hide the nginx version from error pages and the Server header. +server_tokens off; + +# HSTS — one year, with subdomains, preload-eligible. Only safe to +# enable once HTTPS is the only listener (the :80 vhost should already +# 301 → https). Once preload is requested via hstspreload.org, the +# max-age cannot be lowered without removing from the list manually. +add_header Strict-Transport-Security + "max-age=31536000; includeSubDomains; preload" always; + +# Block MIME-sniffing — required for safe text/plain and svg responses. +add_header X-Content-Type-Options "nosniff" always; + +# Defense against clickjacking. Modern browsers honor CSP frame-ancestors +# (set below); X-Frame-Options is kept for legacy clients. +add_header X-Frame-Options "DENY" always; + +# Strip the Referer header to "scheme + host" on cross-origin requests. +add_header Referrer-Policy "strict-origin-when-cross-origin" always; + +# Default-deny powerful APIs. interest-cohort opts out of FLoC/Topics. +add_header Permissions-Policy + "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + +# Content Security Policy. Shipped in Report-Only mode initially — +# promote to enforcing (strip the "-Report-Only" suffix) once the +# report stream has been clean for a week. +# +# External origins justified inline: +# cdn.jsdelivr.net KaTeX CSS + JS, Vega / Vega-Lite / Vega-Embed +# *.basemaps.cartocdn.com Leaflet basemap tiles (photography map only) +# +# Why 'unsafe-inline' on style: +# - photography.html emits for +# palette swatches (data-driven, can't be hashed). +# - KaTeX adds inline styles when rendering math at runtime. +# +# Why 'unsafe-eval' on script: +# - vega-embed compiles Vega-Lite specs at runtime via new Function(). +# Removing this would require pre-compiling specs at build time. +# +# To collect violation reports, set up a `report-uri` endpoint and add +# `report-uri /csp-report;` (and/or `report-to ;`) below. +add_header Content-Security-Policy-Report-Only + "default-src 'self'; \ + script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net; \ + style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; \ + img-src 'self' data: https://*.basemaps.cartocdn.com; \ + font-src 'self' data:; \ + connect-src 'self'; \ + frame-ancestors 'none'; \ + base-uri 'self'; \ + form-action 'self'; \ + object-src 'none'; \ + upgrade-insecure-requests" always; diff --git a/nginx/static-assets.conf b/nginx/static-assets.conf index f44b69f..a864b2f 100644 --- a/nginx/static-assets.conf +++ b/nginx/static-assets.conf @@ -70,9 +70,10 @@ location ^~ /models/ { } # Per-extension caching for assets that live alongside HTML. CSS and JS -# in this repo are not fingerprinted, so a 1-day cache with must-revalidate -# keeps them responsive to deploys without forcing a fetch on every page. +# in this repo are not fingerprinted, so a 1-hour cache with must-revalidate +# keeps them responsive to deploys (~1h staleness window for warm clients) +# without forcing a fetch on every page navigation. location ~* \.(?:css|js|mjs|woff2?|svg|webp|png|jpg|jpeg|ico)$ { - add_header Cache-Control "public, max-age=86400, must-revalidate" always; + add_header Cache-Control "public, max-age=3600, must-revalidate" always; access_log off; } diff --git a/nginx/vhost.conf.example b/nginx/vhost.conf.example new file mode 100644 index 0000000..7ef1a4a --- /dev/null +++ b/nginx/vhost.conf.example @@ -0,0 +1,62 @@ +# vhost.conf.example — reference vhost for levineuwirth.org. +# +# The live vhost on the VPS is the source of truth and lives at +# /etc/nginx/sites-available/levineuwirth.conf. `make deploy` does not +# touch the vhost — it only rsyncs _site/ to the document root. This +# file exists so the canonical structure (which snippets to include, +# in what order) is documented in the repo. +# +# To adopt: copy this file to /etc/nginx/sites-available/levineuwirth.conf +# on the VPS, fill in the certificate paths, and `nginx -t && systemctl +# reload nginx`. The three snippets it includes ship from this repo's +# nginx/ directory and should be installed under /etc/nginx/snippets/. + +# ── http { } scope ────────────────────────────────────────────────── +# popup-proxy.conf consumes a `proxy_cache_path` defined in http { }. +# Place this directive in nginx.conf or a conf.d/ file: +# +# proxy_cache_path /var/cache/nginx/popup-proxy +# levels=1:2 keys_zone=popup_proxy:16m +# max_size=512m inactive=60d use_temp_path=off; +# +# popup-proxy.conf also defines a `limit_req_zone` for PubMed; place +# its companion zone definition in http { } as well: +# +# limit_req_zone $binary_remote_addr zone=pubmed:1m rate=3r/s; + +# ── HTTPS server ──────────────────────────────────────────────────── +server { + listen 443 ssl; + http2 on; + listen [::]:443 ssl; + + server_name levineuwirth.org; + root /var/www/levineuwirth.org; + index index.html; + + ssl_certificate /etc/letsencrypt/live/levineuwirth.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/levineuwirth.org/privkey.pem; + + # Order matters: security-headers first so add_header directives + # propagate into the locations defined by the other snippets. + include snippets/security-headers.conf; + include snippets/static-assets.conf; + include snippets/popup-proxy.conf; + + # Static-site fallback. Pretty URLs first (foo/index.html, foo.html), + # then 404. + location / { + try_files $uri $uri/index.html $uri.html =404; + } + + # Custom 404. The build emits _site/404.html. + error_page 404 /404.html; +} + +# ── HTTP → HTTPS redirect ─────────────────────────────────────────── +server { + listen 80; + listen [::]:80; + server_name levineuwirth.org; + return 301 https://$host$request_uri; +}