- Caddy: custom image with caddy-ratelimit plugin, security headers (X-Frame-Options, HSTS, CSP-adjacent, etc.), per-IP rate limiting on auth/scrape/global zones, static error pages (502/503/504), fix routing to remove /api/scrape/* and /api/chapter-text-preview/* direct-to-backend (were bypassing SvelteKit auth middleware) - docker-compose: Caddy build context + error volume, Watchtower service (label-enable mode, 5 min poll), watchtower labels on backend/runner/ui - Scraper: ScrapeChapterList uses retryGet (9 attempts, Retry-After backoff) to fix 429-induced chapter list failures; upTo param stops pagination early for range scrapes - UI: Browse→Catalogue rename (routes, API, links), admin scrape page Continue/Retry buttons, +error.svelte branded error page, type cleanup (removed dead exports, added BookPreviewMeta/BookPreviewResponse to scraper.ts) - Meilisearch: meta_updated field, sort=update fix, facet distribution - Docs: reorganise into docs/d2/ and docs/mermaid/ subdirectories, update all diagrams to reflect Caddy/Watchtower/routing changes, add api-routing.d2 ownership map with auth-level colour coding, regenerate SVGs
161 lines
5.5 KiB
Caddyfile
161 lines
5.5 KiB
Caddyfile
# v3/Caddyfile
|
|
#
|
|
# Caddy reverse proxy for LibNovel v3.
|
|
# Custom build includes github.com/mholt/caddy-ratelimit.
|
|
#
|
|
# Environment variables consumed (set in docker-compose.yml):
|
|
# DOMAIN — public hostname, e.g. libnovel.example.com
|
|
# Use "localhost" for local dev (no TLS cert attempted).
|
|
# CADDY_ACME_EMAIL — Let's Encrypt notification email (empty = no email)
|
|
#
|
|
# Routing rules:
|
|
# /health → backend:8080 (liveness probe)
|
|
# /scrape* → backend:8080 (Go admin scrape endpoints)
|
|
# /api/browse → backend:8080 (MinIO-cached browse pages)
|
|
# /api/book-preview/* → backend:8080 (live scrape, no store write)
|
|
# /api/chapter-text/* → backend:8080 (chapter markdown from MinIO)
|
|
# /api/reindex/* → backend:8080 (rebuild chapter index)
|
|
# /api/cover/* → backend:8080 (proxy cover image)
|
|
# /api/audio-proxy/* → backend:8080 (proxy generated audio)
|
|
# /avatars/* → minio:9000 (presigned avatar GETs)
|
|
# /* (everything else) → ui:3000 (SvelteKit — handles all
|
|
# remaining /api/* routes)
|
|
#
|
|
# Routes intentionally removed from direct-to-backend:
|
|
# /api/scrape/* — SvelteKit has /api/scrape/ counterparts
|
|
# that enforce auth; routing directly would
|
|
# bypass SK middleware.
|
|
# /api/chapter-text-preview/* — Same: SvelteKit owns
|
|
# /api/chapter-text-preview/[slug]/[n].
|
|
{
|
|
# Email for Let's Encrypt ACME account registration.
|
|
# When CADDY_ACME_EMAIL is set this expands to e.g. "email you@example.com".
|
|
# When unset it expands to an empty string and is silently ignored.
|
|
{$CADDY_ACME_EMAIL:}
|
|
}
|
|
|
|
(security_headers) {
|
|
header {
|
|
# Prevent clickjacking
|
|
X-Frame-Options "SAMEORIGIN"
|
|
# Prevent MIME-type sniffing
|
|
X-Content-Type-Options "nosniff"
|
|
# Minimal referrer info for cross-origin requests
|
|
Referrer-Policy "strict-origin-when-cross-origin"
|
|
# Restrict powerful browser features
|
|
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
|
|
# Enforce HTTPS for 1 year (includeSubDomains)
|
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
# Enable XSS filter in older browsers
|
|
X-XSS-Protection "1; mode=block"
|
|
# Remove server identity header
|
|
-Server
|
|
}
|
|
}
|
|
|
|
{$DOMAIN:localhost} {
|
|
import security_headers
|
|
|
|
# ── Rate limiting ─────────────────────────────────────────────────────────
|
|
# Auth endpoints: strict — 10 req/min per IP
|
|
rate_limit {
|
|
zone auth_zone {
|
|
match {
|
|
path /api/auth/login /api/auth/register /api/auth/change-password
|
|
}
|
|
key {remote_host}
|
|
window 1m
|
|
events 10
|
|
}
|
|
}
|
|
|
|
# Admin scrape endpoints: moderate — 20 req/min per IP
|
|
rate_limit {
|
|
zone scrape_zone {
|
|
match {
|
|
path /scrape*
|
|
}
|
|
key {remote_host}
|
|
window 1m
|
|
events 20
|
|
}
|
|
}
|
|
|
|
# Global: 300 req/min per IP (covers everything)
|
|
rate_limit {
|
|
zone global_zone {
|
|
key {remote_host}
|
|
window 1m
|
|
events 300
|
|
}
|
|
}
|
|
|
|
# ── Liveness probe ────────────────────────────────────────────────────────
|
|
handle /health {
|
|
reverse_proxy backend:8080
|
|
}
|
|
|
|
# ── Scrape task creation (Go backend only) ────────────────────────────────
|
|
handle /scrape* {
|
|
reverse_proxy backend:8080
|
|
}
|
|
|
|
# ── Backend-only API paths ────────────────────────────────────────────────
|
|
# These paths are served exclusively by the Go backend and have no
|
|
# SvelteKit counterpart. Routing them here skips SK intentionally.
|
|
handle /api/browse {
|
|
reverse_proxy backend:8080
|
|
}
|
|
handle /api/book-preview/* {
|
|
reverse_proxy backend:8080
|
|
}
|
|
handle /api/chapter-text/* {
|
|
reverse_proxy backend:8080
|
|
}
|
|
handle /api/reindex/* {
|
|
reverse_proxy backend:8080
|
|
}
|
|
handle /api/cover/* {
|
|
reverse_proxy backend:8080
|
|
}
|
|
handle /api/audio-proxy/* {
|
|
reverse_proxy backend:8080
|
|
}
|
|
|
|
# ── MinIO avatars bucket (presigned GET only) ─────────────────────────────
|
|
handle /avatars/* {
|
|
reverse_proxy minio:9000
|
|
}
|
|
|
|
# ── SvelteKit UI (catch-all — includes all remaining /api/* routes) ───────
|
|
handle {
|
|
reverse_proxy ui:3000
|
|
}
|
|
|
|
# ── Caddy-level error pages ───────────────────────────────────────────────
|
|
# These fire when the upstream (backend or ui) is completely unreachable.
|
|
# SvelteKit's own +error.svelte handles application-level errors (404, 500).
|
|
handle_errors 502 {
|
|
root * /srv/errors
|
|
rewrite * /502.html
|
|
file_server
|
|
}
|
|
handle_errors 503 {
|
|
root * /srv/errors
|
|
rewrite * /503.html
|
|
file_server
|
|
}
|
|
handle_errors 504 {
|
|
root * /srv/errors
|
|
rewrite * /504.html
|
|
file_server
|
|
}
|
|
|
|
# ── Logging ───────────────────────────────────────────────────────────────
|
|
log {
|
|
output stdout
|
|
format json
|
|
}
|
|
}
|