# ALM Talent - Caddy edge (DRAFT for Stage 6, branch prep/deploy).
#
# - Automatic HTTPS (Let's Encrypt) for {$APP_DOMAIN} once its DNS A record points at this server.
#   http:// is redirected to https:// automatically. Certificates live in the caddy-data volume.
# - /api/*  -> Laravel (PHP-FPM over FastCGI). The /api prefix is removed, exactly as OpnForm's own
#             nginx ingress does (docker/nginx.conf upstream), so Laravel routes stay at "/".
#   /open/*, /local/temp/*, /forms/assets/*, /storage/* -> Laravel unchanged (OpnForm routes, same as upstream;
#             /storage/* = signed local-disk URLs, checked by Laravel).
# - /api/alm/public/media/* -> straight from the alm_public disk when the file is there, else Laravel.
# - everything else -> Nuxt (SSR + SPA).
# - :8080 (inside the Docker network only, never published) -> Laravel for Nuxt server-side rendering.
#
# CSP, Permissions-Policy and X-Frame-Options are owned by the app (nuxt-security, BUILD_PLAN M8.4);
# Caddy only adds them when the app did not ("?" = default value), and always adds HSTS.
{
	admin localhost:2019
	servers :8080 {
		# Only the Nuxt container may hand us a client IP (X-Forwarded-For) for SSR calls.
		trusted_proxies static {$ALM_WEB_IP:172.30.10.20}/32
	}
	servers {
		timeouts {
			read_header 15s
			idle 2m
		}
	}
	# Caddy's own runtime log (stderr -> Docker json-file, "bin/alm logs caddy"). Its error lines carry the request too
	# (e.g. reverse_proxy "aborting with incomplete response" when a visitor cancels), so they get the same masking as
	# the access log below: network part of the IP only, tokens redacted (audit 2026-10-17 ops-2, D-054, UAE PDPL).
	# Keep the request>uri line identical to the one in (access_log). Access lines themselves never come here.
	log default {
		output stderr
		format filter {
			wrap json
			fields {
				request>remote_ip ip_mask 24 48
				request>client_ip ip_mask 24 48
				request>headers>X-Forwarded-For ip_mask 24 48
				request>uri regexp "(/(?:l|invites)/)[^/?]+|(/)[a-z.~-]*[A-Z0-9_][A-Za-z0-9._~-]{19,}|(/)[A-Za-z0-9._~-]{19,}[A-Z0-9_][A-Za-z0-9._~-]*|((?:signature|token|code|expires)=)[^&]+" "${1}${2}${3}${4}[redacted]"
			}
		}
		exclude http.log.access
	}
}

# ---------------------------------------------------------------------------------------------------
# Snippets
# ---------------------------------------------------------------------------------------------------

# Laravel through PHP-FPM. Must be imported inside handle_path/handle so the URI is already final:
# REQUEST_URI is what Laravel routes on, so it gets the (prefix-stripped) URI, not the original one.
(laravel) {
	vars laravel_uri {uri}
	rewrite * /index.php
	reverse_proxy api:9000 {
		transport fastcgi {
			root /var/www/html/public
			split .php
			env REQUEST_URI {vars.laravel_uri}
			env SCRIPT_NAME /index.php
			dial_timeout 5s
			read_timeout 300s
			write_timeout 300s
		}
	}
}

# Same for the internal SSR listener: present the request to Laravel as the public HTTPS site, so
# generated URLs and Secure cookies are right even though Nuxt talks plain HTTP inside Docker.
(laravel_internal) {
	vars laravel_uri {uri}
	rewrite * /index.php
	reverse_proxy api:9000 {
		transport fastcgi {
			root /var/www/html/public
			split .php
			env REQUEST_URI {vars.laravel_uri}
			env SCRIPT_NAME /index.php
			env HTTPS on
			env REQUEST_SCHEME https
			env SERVER_PORT 443
			env HTTP_HOST {$APP_DOMAIN}
			env SERVER_NAME {$APP_DOMAIN}
			dial_timeout 5s
			read_timeout 120s
			write_timeout 120s
		}
	}
}

(security_headers) {
	header {
		Strict-Transport-Security "max-age=31536000"
		?X-Content-Type-Options "nosniff"
		?Referrer-Policy "strict-origin-when-cross-origin"
		?X-Frame-Options "DENY"
		?Permissions-Policy "camera=(self), microphone=(self), geolocation=(), payment=(), usb=(), interest-cohort=()"
		-Server
		-X-Powered-By
	}
}

(access_log) {
	log {
		# D-054: access logs (shortened IP, page, time, browser) are kept 14 days, then deleted. A new file every
		# day (or at 50 MiB); files older than 14 days are removed by Caddy itself. Volume "caddy-logs".
		# Read them with: bin/alm exec caddy tail -n 50 /var/log/caddy/access.log
		output file /var/log/caddy/access.log {
			roll_size 50MiB
			roll_interval 24h
			roll_keep_for 336h
			roll_keep 20
		}
		format filter {
			wrap json
			fields {
				# Personal data minimisation (UAE PDPL): keep only the network part of client IPs.
				request>remote_ip ip_mask 24 48
				request>client_ip ip_mask 24 48
				request>headers>X-Forwarded-For ip_mask 24 48
				# Tokens never reach the logs (audit r1 S6), in page and /api paths alike: any path segment of
				# 20+ URL characters that holds a capital, digit or "_" (share links, claim / invite / reset tokens,
				# content hashes; route names are lower-case kebab and stay readable), the short codes of /l/ and
				# onboarding invites, and signed-URL query values (the key name is kept: "signature=[redacted]").
				request>uri regexp "(/(?:l|invites)/)[^/?]+|(/)[a-z.~-]*[A-Z0-9_][A-Za-z0-9._~-]{19,}|(/)[A-Za-z0-9._~-]{19,}[A-Z0-9_][A-Za-z0-9._~-]*|((?:signature|token|code|expires)=)[^&]+" "${1}${2}${3}${4}[redacted]"
			}
		}
	}
}

# ---------------------------------------------------------------------------------------------------
# Public site
# ---------------------------------------------------------------------------------------------------
{$APP_DOMAIN} {
	import access_log
	import security_headers
	encode zstd gzip

	# Optional per-server snippets (e.g. staging basic auth): caddy/site.d/*.caddy
	import /etc/caddy/site.d/*.caddy

	request_body {
		max_size {$CADDY_MAX_BODY:25MB}
	}

	# Health probes are not worth a log line every few minutes.
	log_skip /api/up

	# Safety net: private and share pages are never indexed (the app sets this too, PLAN_TECH_NOTES T15).
	@noindex path /t/* /s/* /sel/* /l/* /client /client/* /me /me/* /admin /admin/* /team/* /signin /claim /claim/* /compcard/*
	header @noindex >X-Robots-Tag "noindex, nofollow, noai, noimageai"

	# handle / handle_path blocks are mutually exclusive and tried in the order written below.

	# Internet Archive (audit 2026-10-17 ops-1): a Wayback "save page" extension in a team browser made archive.org
	# fetch private pages seconds after they were opened, a password-reset link and /admin pages included.
	# X-Robots-Tag does not stop archiving, so its crawlers get a 403: by name on every page (robots.txt stays readable
	# and says the same), and from the archive's own networks (AS7941; its headless browser sends an ordinary Chrome
	# name) on every private, token or API path. Public pages stay reachable for everyone else.
	@archive_bots {
		header_regexp User-Agent (?i)(?:archive\.org|ia_archiver)
		not path /robots.txt
	}
	handle @archive_bots {
		respond "Archiving this site is not allowed." 403
	}
	@archive_net_private {
		client_ip 207.241.224.0/20 208.70.24.0/21
		path /api/* /storage/* /open/* /admin /admin/* /me /me/* /client /client/* /claim /claim/* /password/* /team/* /alerts/* /applications/* /pay/* /reveal-consent/* /s/* /sel/* /l/* /i/* /compcard/* /forms/* /home /selection
	}
	handle @archive_net_private {
		respond "Archiving this site is not allowed." 403
	}

	# Development-only tools never answer in production (the app also disables them; ALM_DEV_TOOLS).
	@devtools path /dev /dev/* /api/dev /api/dev/* /_nuxt/__dev* /__nuxt_devtools__*
	handle @devtools {
		respond 404
	}

	# Approved public directory images (alm_public disk, ALM_STORAGE=local) straight from disk, so PHP-FPM is
	# not woken for every thumbnail. The URL maps 1:1 onto the disk layout p/{hash[0:2]}/{hash}-{variant}.{ext}
	# (MediaPaths::publicDerivative, route alm.media.public.file, which only checks that the file exists).
	# A file that is not on disk (ALM_STORAGE=r2, or not published) falls through to Laravel below.
	@public_media {
		path_regexp ^/api/alm/public/media/p/[0-9a-f]{2}/[0-9a-f]{64}-(?:thumb|card|full|og)\.(?:webp|jpg)$
		file {
			root /srv/api-storage/app/alm/public
			try_files /{http.request.uri.path.4}/{http.request.uri.path.5}/{http.request.uri.path.6}
		}
	}
	handle @public_media {
		uri strip_prefix /api/alm/public/media
		root * /srv/api-storage/app/alm/public
		header Cache-Control "public, max-age=31536000, immutable"
		header X-Robots-Tag "noindex, noimageai"
		file_server
	}

	handle_path /api/* {
		import laravel
	}

	# /storage/* = Laravel's signed local-disk URLs (form exports, file answers, local uploads): Laravel checks the
	# signature (ServeFile), so they must never be served from disk by Caddy (audit r1 S7).
	@laravel_direct path /open/* /local/temp/* /forms/assets/* /storage/*
	handle @laravel_direct {
		import laravel
	}

	handle {
		reverse_proxy web:3000 {
			# Caddy sets X-Forwarded-For/-Proto/-Host itself; Nuxt forwards X-Forwarded-For on SSR calls.
			transport http {
				dial_timeout 5s
				response_header_timeout 60s
			}
		}
	}
}

# ---------------------------------------------------------------------------------------------------
# Internal listener for Nuxt SSR -> Laravel. Port 8080 is NOT published in docker-compose.yml.
# NUXT_PRIVATE_API_BASE=http://caddy:8080/api
# ---------------------------------------------------------------------------------------------------
http://:8080 {
	@devtools path /api/dev /api/dev/*
	handle @devtools {
		respond 404
	}
	handle_path /api/* {
		import laravel_internal
	}
	handle {
		respond 404
	}
}
