# 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 (main domain):
#   /health                        → backend:8080  (liveness probe)
#   /scrape*                       → backend:8080  (Go admin scrape endpoints)
#   /api/book-preview/*            → backend:8080  (live scrape, no store write)
#   /api/chapter-text/*            → backend:8080  (chapter markdown from MinIO)
#   /api/chapter-markdown/*        → 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)
#   /audio/*                       → minio:9000    (presigned audio GETs)
#   /chapters/*                    → minio:9000    (presigned chapter GETs)
#   /*  (everything else)          → ui:3000       (SvelteKit — handles all
#                                                   remaining /api/* routes)
#
# Subdomain routing:
#   feedback.libnovel.cc           → fider:3000    (user feedback / feature requests)
#   errors.libnovel.cc             → glitchtip-web:8000  (error tracking)
#   analytics.libnovel.cc          → umami:3000    (page analytics)
#   logs.libnovel.cc               → dozzle:8080   (Docker log viewer)
#   uptime.libnovel.cc             → uptime-kuma:3001  (uptime monitoring)
#   push.libnovel.cc               → gotify:80     (push notifications)
#   search.libnovel.cc             → meilisearch:7700  (search index — homelab runner)
#
# 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].
#   /api/browse                    — Endpoint removed; browse snapshot system
#                                    was deleted.

{
	# 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 the variable expands to an empty string and Caddy ignores it.
	email {$CADDY_ACME_EMAIL:}

	# CrowdSec bouncer — streams decisions from the CrowdSec LAPI every 15s.
	# CROWDSEC_API_KEY is injected at runtime via crowdsec/.crowdsec.env.
	# The default "disabled" placeholder makes the bouncer fail-open (warn,
	# pass traffic) when no key is configured — Caddy still starts cleanly.
	crowdsec {
		api_url http://crowdsec:8080
		api_key {$CROWDSEC_API_KEY:disabled}
		ticker_interval 15s
	}

	# ── Redis TCP proxy via layer4 ────────────────────────────────────────────
	# Exposes homelab Redis over TLS for Asynq job enqueueing from the backend.
	# Listens on :6380 (all interfaces). TLS is terminated here using the cert
	# for redis.libnovel.cc; traffic is proxied to the homelab Redis instance.
	# Requires the caddy-l4 module in the custom Caddy build.
	layer4 {
		:6380 {
			route {
				tls {
					connection_policy {
						match {
							sni redis.libnovel.cc
						}
					}
				}
				proxy {
					upstream {$HOMELAB_REDIS_ADDR:192.168.0.109:6379}
				}
			}
		}
	}
}

(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

	# ── CrowdSec bouncer ──────────────────────────────────────────────────────
	# Checks every incoming request against CrowdSec decisions.
	# Banned IPs receive a 403; all others pass through unchanged.
	route {
		crowdsec
	}

	# ── 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/book-preview/* {
		reverse_proxy backend:8080
	}
	handle /api/chapter-text/* {
		reverse_proxy backend:8080
	}
	handle /api/chapter-markdown/* {
		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 bucket paths (presigned URLs) ──────────────────────────────────
	# MinIO path-style presigned URLs include the bucket name as the first
	# path segment. MINIO_PUBLIC_ENDPOINT points here, so Caddy must proxy
	# these paths directly to MinIO — no auth layer needed (the presigned
	# signature itself enforces access and expiry).
	handle /avatars/* {
		reverse_proxy minio:9000
	}
	handle /audio/* {
		reverse_proxy minio:9000
	}
	handle /chapters/* {
		reverse_proxy minio:9000
	}

	# ── SvelteKit UI (catch-all — includes all remaining /api/* routes) ───────
	handle {
		reverse_proxy ui:3000 {
			# Active health check: Caddy polls /health every 5 s and marks the
			# upstream down immediately when it fails. Combined with
			# lb_try_duration this means Watchtower container replacements
			# show the maintenance page within a few seconds instead of
			# hanging or returning a raw connection error to the browser.
			health_uri      /health
			health_interval 5s
			health_timeout  2s
			health_status   200

			# If the upstream is down, fail fast (don't retry for longer than
			# 3 s) and let Caddy's handle_errors 502/503 take over.
			lb_try_duration 3s
		}
	}

	# ── 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 404 {
		root * /srv/errors
		rewrite * /404.html
		file_server
	}
	handle_errors 500 {
		root * /srv/errors
		rewrite * /500.html
		file_server
	}
	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 ───────────────────────────────────────────────────────────────
	# JSON log file read by CrowdSec for threat detection.
	log {
		output file /var/log/caddy/access.log {
			roll_size     100MiB
			roll_keep     5
			roll_keep_for 720h
		}
		format json
	}
}

# ── Tooling subdomains ────────────────────────────────────────────────────────
# feedback.libnovel.cc, errors.libnovel.cc, analytics.libnovel.cc,
# logs.libnovel.cc, uptime.libnovel.cc, push.libnovel.cc, grafana.libnovel.cc
# are now routed via Cloudflare Tunnel directly to the homelab (192.168.0.109).
# No Caddy rules needed here — Cloudflare handles TLS termination and routing.

# ── PocketBase: exposed for homelab runner task polling ───────────────────────
# Allows the homelab runner to claim tasks and write results via the PB API.
# Admin UI is also accessible here for convenience.
pb.libnovel.cc {
	import security_headers
	reverse_proxy pocketbase:8090
}

# ── MinIO S3 API: exposed for homelab runner object writes ────────────────────
# The homelab runner connects here as MINIO_ENDPOINT to PutObject audio/chapters.
# Also used as MINIO_PUBLIC_ENDPOINT for presigned URL generation.
storage.libnovel.cc {
	import security_headers
	reverse_proxy minio:9000
}

# ── Meilisearch: exposed for homelab runner search indexing ──────────────────
# The homelab runner connects here as MEILI_URL to index books after scraping.
# Protected by MEILI_MASTER_KEY bearer token — Meilisearch enforces auth on
# every request; Caddy just terminates TLS.
search.libnovel.cc {
	import security_headers
	reverse_proxy meilisearch:7700
}

# ── Redis TLS cert anchor ─────────────────────────────────────────────────────
# This virtual host exists solely so Caddy obtains and caches a TLS certificate
# for redis.libnovel.cc.  The layer4 block above uses that cert to terminate TLS
# on :6380 (Asynq job-queue channel from prod → homelab Redis).
# The HTTP route itself just returns 404 — no real traffic expected here.
redis.libnovel.cc {
	respond 404
}
