# Hosted demo reverse proxy (edge.yml). Automatic HTTPS (Let's Encrypt) for
# DEMO_DOMAIN. Caddy core has no rate limiting (that needs the third-party
# caddy-ratelimit plugin and a custom build), so there is none
# (docs/reference/hosted-demo.md).
{
	admin off
	# Caddy's own log (docker logs ha-nova-demo-caddy: ACME, TLS, errors)
	# rotates by size only, so it keeps nothing about visitors: error and
	# proxy entries lose the request (address, headers, URI) they carry, and
	# error texts their addresses ("write tcp a:443->visitor:port: ...").
	log default {
		format filter {
			wrap json
			request delete
			error regexp \d+\.\d+\.\d+\.\d+(:\d+)? [ip]
		}
	}
}

# In the hanova.app look (night sky, the star from site/icon.svg via /_demo/).
# Style attributes only: braces in a respond body read as placeholders.
(restarting_html) {
	header Content-Type "text/html; charset=utf-8"
	header Retry-After "60"
	# handle_errors skips the site's deferred headers; its links go out.
	header Referrer-Policy "no-referrer"
	respond `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="refresh" content="15"><title>Demo is restarting</title><body style="margin:0;min-height:100vh;background:#0A1628 linear-gradient(180deg,#0A0E1A 0%,#0D1525 45%,#0A1628 100%);color:#B8CCE0;font:17px/1.65 -apple-system,'Helvetica Neue','Segoe UI',sans-serif;text-align:center"><main style="max-width:34em;margin:0 auto;padding:clamp(3rem,12vh,6rem) 1.5rem"><img src="/_demo/icon.svg" alt="" width="96" height="96" style="display:block;margin:0 auto"><p style="margin:1.25rem 0 0;font-size:1rem;font-weight:600;letter-spacing:.34em;padding-left:.34em">HA NOVA</p><h1 style="margin:.75rem 0 0;color:#E8EDF2;font-size:2rem;line-height:1.2">Demo is restarting</h1><p style="margin:1.25rem 0 0">The demo is being reset (at the top of every hour, UTC) or restarted. Back in a few minutes; this page reloads by itself.</p><p style="margin:1.5rem 0 0"><a href="/start" style="color:#18BCF2;text-underline-offset:3px">About this demo</a> · <a href="https://hanova.app/privacy.html" style="color:#18BCF2;text-underline-offset:3px">Privacy</a></p></main>` 503
}

(resetting_json) {
	header Content-Type "application/json"
	header Retry-After "60"
	respond `{"error":"demo_resetting"}` 503
}

# A chat app's token refresh (/auth/token) while the house is down: an OAuth
# JSON error with Retry-After, never the HTML page, so it retries later; its
# sign-in outlives the reset (reset.sh, chat.mjs keep/restore).
(resetting_oauth) {
	header Content-Type "application/json"
	header Retry-After "60"
	respond `{"error":"temporarily_unavailable","error_description":"The demo house is resetting. Try again in a few minutes."}` 503
}

# Add to Claude (/api/ha_nova/mcp): a JSON-RPC error, so the chat can say why.
(resetting_rpc) {
	header Content-Type "application/json"
	header Retry-After "60"
	respond `{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"The demo house is resetting (every hour at :00 UTC). Try again in a few minutes. Your connection stays."}}` 503
}

{$DEMO_DOMAIN} {
	# Access log: client IPs truncated (/24, /48), no headers, no auth query
	# parameters. For abuse handling only. A new file at 00:00 UTC every day
	# (by the clock, even with no requests, once Caddy logged one since its
	# start) and at 10 MiB; a rolled file goes at the first roll more than
	# 4 days (roll_keep_for) after its own: no entry older than 6 days. A
	# missed midnight (Caddy down or restarted) is caught by the hourly
	# reset.sh, which deletes any file whose oldest entry is over 5 days old:
	# a day of slack for late or skipped resets under the privacy page's 6.
	log {
		output file /logs/access.log {
			roll_size 10MiB
			roll_at 00:00
			roll_keep 7
			roll_keep_for 96h
		}
		format filter {
			request>remote_ip ip_mask 24 48
			request>client_ip ip_mask 24 48
			request>headers delete
			request>uri query {
				delete authSig
				delete token
				delete code
			}
			resp_headers delete
		}
	}
	request_body {
		max_size 1MB
	}
	# The visitor's IPv4 with the last byte zeroed, the only client address
	# HA gets (X-Forwarded-For below). A private address stays private
	# (10.x.y.0), so HA's local-only check sees the same network as before.
	# The instance is IPv4-only; anything else becomes one TEST-NET-1 address.
	map {client_ip} {masked_client_ip} {
		~^(\d+\.\d+\.\d+)\.\d+$ "${1}.0"
		default 192.0.2.0
	}
	header {
		Strict-Transport-Security "max-age=31536000"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		# No Referer anywhere: the brand icon CDN (@brand below) and links
		# out never learn the demo's address.
		Referrer-Policy "no-referrer"
		Permissions-Policy "camera=(), microphone=(), geolocation=()"
		# Visitors are admins: they can add dashboard resources, iframe
		# dashboards, and markdown with images. Only this origin may serve
		# scripts and frames, so nobody's browser is pointed elsewhere.
		# Images and connections: this origin plus Home Assistant's public
		# brand icon CDN (@brand below), nothing else. connect-src needs it
		# because HA's service worker fetch()es /api/brands/ images and
		# follows the 302 under its own CSP (this header on /sw-modern.js).
		Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://brands.home-assistant.io; media-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' data: blob: https://brands.home-assistant.io; worker-src 'self' blob:; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
		-Server
	}

	# Unauthenticated file hosting, backup upload/download, onboarding (open
	# for seconds on every rebuild), relay pairing: never.
	@blocked path /api/backup/* /local/* /api/tts_proxy/* /relay/pair* /relay/home* /api/onboarding* /onboarding.html
	# Integration icons. HA serves them at /api/brands/ by fetching
	# brands.home-assistant.io itself, which the house cannot (no egress):
	# broken icons in Settings. The visitor's browser gets the same public
	# image from the CDN instead, by a redirect that keeps only the domain and
	# file name (HA's eight icon/logo names), never the token query. CDN paths as in
	# HA's brands integration: _/ falls back to a placeholder, brands/ is exact.
	@brand path_regexp brand ^/api/brands/integration/([a-z0-9_]+)/((?:dark_)?(?:icon|logo)(?:@2x)?\.png)$
	@brand_exact {
		path_regexp brand ^/api/brands/integration/([a-z0-9_]+)/((?:dark_)?(?:icon|logo)(?:@2x)?\.png)$
		query placeholder=no
	}
	# Landing page, on hanova.app/demo/ (repo markusleben/ha-nova-site):
	# /start, and / without a query only. HA's own redirects to / always
	# carry one (sign-in returns to /?auth_callback=1&code=...), and those
	# must reach HA.
	@landing {
		path /
		expression {http.request.uri.query} == ""
	}
	# reset.sh drops /srv/ready while it rebuilds and touches it only after
	# every gate passed: until then the house and the relay are not exposed.
	@notready {
		not file {
			root /srv
			try_files /ready
		}
	}

	# route keeps this order literally.
	route {
		handle @blocked {
			respond 403
		}
		handle @brand_exact {
			redir https://brands.home-assistant.io/brands/{re.brand.1}/{re.brand.2} 302
		}
		handle @brand {
			redir https://brands.home-assistant.io/_/{re.brand.1}/{re.brand.2} 302
		}
		handle @landing {
			redir https://hanova.app/demo/ 302
		}
		handle /start {
			redir https://hanova.app/demo/ 302
		}
		# The restarting page's star; served while the house is down too.
		handle_path /_demo/* {
			root * /site
			file_server
		}
		handle /abuse {
			# ABUSE_CONTACT is [A-Za-z0-9@._:/+-] only (bootstrap.sh): no markup.
			header Content-Type "text/html; charset=utf-8"
			respond `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Report abuse</title><body style="margin:0;background:#0A1628;color:#B8CCE0;font:17px/1.65 -apple-system,'Helvetica Neue','Segoe UI',sans-serif;text-align:center"><main style="max-width:34em;margin:0 auto;padding:clamp(3rem,12vh,6rem) 1.5rem"><p style="margin:0">Report abuse of this public demo: {$ABUSE_CONTACT}</p><p style="margin:1.5rem 0 0"><a href="/start" style="color:#18BCF2;text-underline-offset:3px">About this demo</a> · <a href="https://hanova.app/privacy.html" style="color:#18BCF2;text-underline-offset:3px">Privacy</a></p></main>` 200
		}
		handle /relay/* {
			handle @notready {
				import resetting_json
			}
			uri strip_prefix /relay
			# The relay's own 502s carry its JSON error envelope; only
			# transport failures (handle_errors) become demo_resetting.
			reverse_proxy relay:8791 {
				header_up X-Forwarded-For {masked_client_ip}
			}
		}
		# Add to Claude: chat apps get JSON-RPC, never the restarting page; the
		# integration's own JSON errors pass. Sign-in (/auth/*,
		# /.well-known/oauth-*) is Home Assistant's, below.
		handle /api/ha_nova/mcp {
			handle @notready {
				import resetting_rpc
			}
			reverse_proxy homeassistant:8123 {
				header_up X-Forwarded-For {masked_client_ip}
			}
		}
		handle {
			handle @notready {
				handle /auth/token {
					import resetting_oauth
				}
				handle {
					import restarting_html
				}
			}
			handle /connect.json {
				header Cache-Control "no-store"
				# The landing page reads ha_version from here. A plain GET
				# needs no preflight, so no other CORS header.
				@get method GET
				header @get Access-Control-Allow-Origin "https://hanova.app"
				root * /srv
				file_server
			}
			handle {
				reverse_proxy homeassistant:8123 {
					# HA keeps only this truncated address (refresh tokens,
					# failed-login notices, its log), never the visitor's.
					header_up X-Forwarded-For {masked_client_ip}
					# An upstream that answers 502-504 (HA starting or
					# stopping) gets the same page as one that is gone.
					@down status 502 503 504
					handle_response @down {
						handle /auth/token {
							import resetting_oauth
						}
						handle {
							import restarting_html
						}
					}
				}
			}
		}
	}

	# Transport errors: the upstream container does not exist or refuses.
	handle_errors 502 503 504 {
		@relay expression {http.request.orig_uri.path}.startsWith("/relay/")
		handle @relay {
			import resetting_json
		}
		handle /api/ha_nova/mcp {
			import resetting_rpc
		}
		handle /auth/token {
			import resetting_oauth
		}
		handle {
			import restarting_html
		}
	}
}

# Host tooling path, published on the host's 127.0.0.1 only and never gated.
# Caddy replaces X-Forwarded-For with the real client (a private Docker
# address), so the local-only owner works here and nowhere else.
http://:18123 {
	handle_path /relay/* {
		reverse_proxy relay:8791
	}
	handle {
		reverse_proxy homeassistant:8123
	}
}
