Skip to content

Commit 444b2dd

Browse files
authored
Merge pull request #237 from spoo-me/feat/next-beta-serving
feat: serve Next frontend on beta.spoo.me
2 parents c838398 + 9a5db69 commit 444b2dd

4 files changed

Lines changed: 237 additions & 0 deletions

File tree

caddy/Caddyfile

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,36 @@
6767
}
6868
}
6969

70+
# Compose one branded error page (beta): when the origin self-describes
71+
# an error (X-Error-Code) and the client meaningfully asked for HTML,
72+
# swap the body for the Next error page while KEEPING the origin status
73+
# (the inner proxy would otherwise answer with Next's 200). JSON clients
74+
# and Accept:*/* scanner probes get the origin response untouched —
75+
# composition costs a Next render. args[0] = the status code.
76+
(compose_beta_err) {
77+
@err{args[0]} {
78+
status {args[0]}
79+
header X-Error-Code *
80+
}
81+
handle_response @err{args[0]} {
82+
@html{args[0]} {
83+
header Accept *text/html*
84+
method GET HEAD
85+
}
86+
handle @html{args[0]} {
87+
rewrite * /_error/{args[0]}?code={rp.header.X-Error-Code}&from={uri}
88+
reverse_proxy spoo_next_beta:3000 {
89+
@ok{args[0]} status 2xx
90+
replace_status @ok{args[0]} {args[0]}
91+
header_up Host {host}
92+
}
93+
}
94+
handle {
95+
copy_response
96+
}
97+
}
98+
}
99+
70100
spoo.me, www.spoo.me {
71101
import common_tls
72102
reverse_proxy app:8000 {
@@ -80,9 +110,88 @@ spoo.me, www.spoo.me {
80110
# container name, not compose service name.
81111
beta.spoo.me {
82112
import common_tls
113+
114+
# BETA-ONLY (do not port to prod): beta is a duplicate of the whole
115+
# site — never let crawlers index it. Wins over the matchers below
116+
# because handle is ordered before reverse_proxy.
117+
@robots path /robots.txt
118+
handle @robots {
119+
header Content-Type "text/plain; charset=utf-8"
120+
respond "User-agent: *
121+
Disallow: /" 200
122+
}
123+
124+
# Only paths with a built Next page belong here — a listed path
125+
# with no page behind it 404s in Next instead of falling through
126+
# to FastAPI.
127+
@next {
128+
path /about /about/*
129+
path /pricing /pricing/*
130+
path /apps /apps/*
131+
path /testimonials /testimonials/*
132+
path /login /signup /forgot-password
133+
path /onboarding /onboarding/*
134+
path /privacy /terms /legal
135+
path /relay/*
136+
path /dashboard /dashboard/*
137+
# Composed-error URL (Next rewrites it to error-pages/ internally).
138+
path /_error/*
139+
path /_next/* /icon.png
140+
path /favicon.ico /favicon.png /favicon.svg
141+
# Next public/ asset dirs + the favicon-proxy route. A new top-level
142+
# dir under public/ needs a line here AND a reserved-aliases entry,
143+
# or it 404s through FastAPI and takes client pages down with it.
144+
path /geo/* /brand/* /icons-3d/*
145+
path /api/favicon
146+
# SEO files (robots.ts/sitemap.ts generators + public/ text files).
147+
# Exact .well-known path only — the rest of that namespace stays
148+
# with the backend for whatever claims it later.
149+
path /robots.txt /sitemap.xml /humans.txt
150+
path /security.txt /.well-known/security.txt
151+
}
152+
reverse_proxy @next spoo_next_beta:3000 {
153+
header_up X-Real-IP {client_ip}
154+
header_up X-Forwarded-For {client_ip}
155+
header_up Host {host}
156+
}
157+
158+
# Paths whose GET is a Next page but whose POST is a shipped backend
159+
# contract: / (the original anonymous shorten API), /contact and
160+
# /report (legacy form intakes), /stats (legacy lookup form) and
161+
# /stats/{code} (the legacy public JSON API). Next serves pages to
162+
# POSTs with a 200, so an unscoped matcher silently breaks every one
163+
# of these for API clients — caught twice on beta.
164+
@next_get {
165+
method GET HEAD
166+
path / /contact /report
167+
path /stats /stats/*
168+
}
169+
reverse_proxy @next_get spoo_next_beta:3000 {
170+
header_up X-Real-IP {client_ip}
171+
header_up X-Forwarded-For {client_ip}
172+
header_up Host {host}
173+
}
174+
175+
# /{code}+ preview — any single segment ending in + (matched against
176+
# the DECODED path, so emoji aliases land here too). No valid alias
177+
# contains +, so this can never shadow a real code.
178+
@preview path_regexp ^/[^/]+\+$
179+
reverse_proxy @preview spoo_next_beta:3000 {
180+
header_up X-Real-IP {client_ip}
181+
header_up X-Forwarded-For {client_ip}
182+
header_up Host {host}
183+
}
184+
185+
# Everything else: short codes, /auth/*, /oauth/*, /api/v1/*, /static/*
83186
reverse_proxy spoo_app_beta:8000 {
84187
header_up X-Real-IP {client_ip}
85188
header_up X-Forwarded-For {client_ip}
189+
190+
import compose_beta_err 404
191+
import compose_beta_err 410
192+
import compose_beta_err 429
193+
import compose_beta_err 451
194+
import compose_beta_err 500
86195
}
87196
}
88197

docker-compose.beta.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ services:
5454
networks:
5555
spoonet:
5656
ipv4_address: 172.30.0.200
57+
# The public link surfaces tenant-scope by Host header, so the
58+
# frontend's server-side fetches must arrive AS beta.spoo.me —
59+
# docker DNS makes the URL's host produce the right Host header
60+
# (the backend strips the :8000). Nothing else on spoonet dials
61+
# this name; Caddy uses container names.
62+
aliases:
63+
- beta.spoo.me
5764
logging:
5865
driver: json-file
5966
options:
@@ -145,6 +152,40 @@ services:
145152
max-file: "2"
146153
tag: "{{.Name}}"
147154

155+
# Next frontend slice — Caddy's beta @next matcher routes page paths
156+
# here; everything else (auth, api, short codes) still hits app-beta
157+
# directly. SPOO_API_URL points the container's SSR fetches and dormant
158+
# rewrites at the beta backend over docker DNS (never out via CF) —
159+
# via the beta.spoo.me network alias, NOT the container name: the
160+
# public link surfaces tenant-scope by Host, and undici won't let a
161+
# fetch override Host, so the URL's host has to be the tenant.
162+
next-beta:
163+
image: ghcr.io/spoo-me/frontend:${NEXT_IMAGE_TAG:-edge}
164+
container_name: spoo_next_beta
165+
restart: always
166+
environment:
167+
- SPOO_API_URL=http://beta.spoo.me:8000
168+
depends_on:
169+
app-beta:
170+
condition: service_healthy
171+
healthcheck:
172+
test: ["CMD", "wget", "-qO-", "--tries=1", "--timeout=3", "http://127.0.0.1:3000/api/health"]
173+
interval: 15s
174+
timeout: 5s
175+
retries: 3
176+
start_period: 20s
177+
mem_limit: 512m
178+
mem_reservation: 128m
179+
networks:
180+
spoonet:
181+
ipv4_address: 172.30.0.204
182+
logging:
183+
driver: json-file
184+
options:
185+
max-size: "5m"
186+
max-file: "2"
187+
tag: "{{.Name}}"
188+
148189
networks:
149190
spoonet:
150191
external: true

edge/spoo-edge-cache/src/index.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,83 @@ const EXCLUDED_PREFIXES = [
3030
"/oauth/",
3131
"/static/",
3232
"/stats/",
33+
"/relay/",
34+
"/onboarding/",
3335
];
3436

37+
/**
38+
* Single-segment page paths that are never short codes — the reserved
39+
* alias list (shared/reserved_aliases.py) guarantees no code can take
40+
* these names, so skipping KV is free. Exact matches only: "aboutus"
41+
* is a legal alias and keeps its lookup. Drift from the Python list is
42+
* benign — a missing entry costs one wasted KV read, nothing more.
43+
*/
44+
const EXCLUDED_EXACT = new Set([
45+
"about",
46+
"api",
47+
"apps",
48+
"auth",
49+
"billing",
50+
"blog",
51+
"brand",
52+
"callback",
53+
"changelog",
54+
"contact",
55+
"dashboard",
56+
"discord",
57+
"docs",
58+
"domains",
59+
"emoji",
60+
"error",
61+
"export",
62+
"favicon",
63+
"features",
64+
"forgot-password",
65+
"geo",
66+
"github",
67+
"health",
68+
"help",
69+
"home",
70+
"humans",
71+
"icon",
72+
"icons-3d",
73+
"images",
74+
"keys",
75+
"legal",
76+
"links",
77+
"login",
78+
"logout",
79+
"metric",
80+
"oauth",
81+
"onboarding",
82+
"pricing",
83+
"privacy",
84+
"privacy-policy",
85+
"profile",
86+
"profile-pictures",
87+
"public",
88+
"register",
89+
"relay",
90+
"report",
91+
"reset",
92+
"result",
93+
"robots",
94+
"security",
95+
"settings",
96+
"signin",
97+
"signup",
98+
"sitemap",
99+
"statistics",
100+
"stats",
101+
"static",
102+
"terms",
103+
"terms-of-service",
104+
"testimonials",
105+
"tos",
106+
"twitter",
107+
"verify",
108+
]);
109+
35110
/**
36111
* KV key for this request, or null when the request can never be a
37112
* cached short-code redirect (wrong method, excluded path, password
@@ -53,6 +128,9 @@ export function lookupKey(request: Request): string | null {
53128

54129
const code = path.slice(1);
55130
if (code.length === 0 || code.includes("/")) return null;
131+
// /{code}+ is preview intent — + is never a legal alias character.
132+
if (code.endsWith("+") || code.endsWith("%2B")) return null;
133+
if (EXCLUDED_EXACT.has(code)) return null;
56134

57135
// Promotion writes keys with the canonical host: lowercase, no www.
58136
const host = url.hostname.toLowerCase().replace(/^www\./, "");

edge/spoo-edge-cache/test/index.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,15 @@ describe("lookupKey routing", () => {
283283
["https://spoo.me/favicon.ico", null],
284284
["https://spoo.me/a/b", null],
285285
["https://spoo.me/abc?password=x", null],
286+
// Next-owned single-segment pages: reserved aliases, exact-match skip.
287+
["https://spoo.me/pricing", null],
288+
["https://spoo.me/onboarding", null],
289+
["https://spoo.me/relay/e", null],
290+
// ...but a reserved-adjacent alias keeps its lookup.
291+
["https://spoo.me/aboutus", "cache:spoo.me:aboutus"],
292+
// /{code}+ is preview intent, never a key (emoji arrives encoded).
293+
["https://spoo.me/abc1234+", null],
294+
["https://spoo.me/%F0%9F%9A%80+", null],
286295
];
287296

288297
for (const [url, expected] of cases) {

0 commit comments

Comments
 (0)