Skip to content

Commit f09d33d

Browse files
authored
Merge pull request #189 from spoo-me/feat/custom-domains-pr4
feat(custom-domains): public API + dashboard + tenant routing policy
2 parents d701259 + fa5b6df commit f09d33d

40 files changed

Lines changed: 3648 additions & 139 deletions

dependencies/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
"""
77

88
from dependencies.auth import (
9+
DOMAIN_MANAGE_SCOPES,
10+
DOMAIN_READ_SCOPES,
911
SHORTEN_SCOPES,
1012
STATS_SCOPES,
1113
URL_MANAGEMENT_SCOPES,
@@ -24,6 +26,7 @@
2426
require_jwt,
2527
require_jwt_verified,
2628
require_scopes,
29+
require_scopes_verified,
2730
require_verified_email,
2831
)
2932
from dependencies.infra import (
@@ -46,8 +49,10 @@
4649
ClickSvc,
4750
ContactSvc,
4851
CredentialSvc,
52+
CustomDomainSvc,
4953
DeviceAuthSvc,
5054
ExportSvc,
55+
FeatureFlagSvc,
5156
OAuthSvc,
5257
PasswordSvc,
5358
ProfilePictureSvc,
@@ -61,8 +66,10 @@
6166
get_click_service,
6267
get_contact_service,
6368
get_credential_service,
69+
get_custom_domain_service,
6470
get_device_auth_service,
6571
get_export_service,
72+
get_feature_flag_service,
6673
get_oauth_service,
6774
get_password_service,
6875
get_profile_picture_service,
@@ -73,6 +80,8 @@
7380
)
7481

7582
__all__ = [
83+
"DOMAIN_MANAGE_SCOPES",
84+
"DOMAIN_READ_SCOPES",
7685
"SHORTEN_SCOPES",
7786
"STATS_SCOPES",
7887
"URL_MANAGEMENT_SCOPES",
@@ -88,8 +97,10 @@
8897
"ContactSvc",
8998
"CredentialSvc",
9099
"CurrentUser",
100+
"CustomDomainSvc",
91101
"DeviceAuthSvc",
92102
"ExportSvc",
103+
"FeatureFlagSvc",
93104
"JwtConfig",
94105
"JwtUser",
95106
"JwtVerifiedUser",
@@ -114,10 +125,12 @@
114125
"get_contact_service",
115126
"get_credential_service",
116127
"get_current_user",
128+
"get_custom_domain_service",
117129
"get_db",
118130
"get_device_auth_service",
119131
"get_email_provider",
120132
"get_export_service",
133+
"get_feature_flag_service",
121134
"get_geoip_service",
122135
"get_jwt_config",
123136
"get_oauth_providers",
@@ -136,5 +149,6 @@
136149
"require_jwt",
137150
"require_jwt_verified",
138151
"require_scopes",
152+
"require_scopes_verified",
139153
"require_verified_email",
140154
]

dependencies/auth.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,12 @@ def check_api_key_scope(user: CurrentUser | None, required_scopes: set[str]) ->
200200
ApiKeyScope.ADMIN_ALL,
201201
}
202202
SHORTEN_SCOPES: set[str] = {ApiKeyScope.SHORTEN_CREATE, ApiKeyScope.ADMIN_ALL}
203+
DOMAIN_MANAGE_SCOPES: set[str] = {ApiKeyScope.DOMAINS_MANAGE, ApiKeyScope.ADMIN_ALL}
204+
DOMAIN_READ_SCOPES: set[str] = {
205+
ApiKeyScope.DOMAINS_MANAGE,
206+
ApiKeyScope.DOMAINS_READ,
207+
ApiKeyScope.ADMIN_ALL,
208+
}
203209

204210

205211
# ── Parameterised scope dependency factories ─────────────────────────────────
@@ -265,6 +271,24 @@ async def _dep(
265271
return _dep
266272

267273

274+
def require_scopes_verified(scopes: set[str]):
275+
"""``require_scopes`` plus email-verification gate.
276+
277+
Use on protected create endpoints that accept BOTH JWT and API key auth.
278+
API key callers must also be email-verified (their underlying user must
279+
have verified the email at signup time).
280+
"""
281+
282+
async def _dep(
283+
user: CurrentUser = Depends(require_scopes(scopes)),
284+
) -> CurrentUser:
285+
if not user.email_verified:
286+
raise EmailNotVerifiedError("Email verification required")
287+
return user
288+
289+
return _dep
290+
291+
268292
# ── Annotated type aliases — community-standard Depends shortcuts ─────────────
269293

270294
AuthUser = Annotated[CurrentUser, Depends(require_auth)]

dependencies/wiring.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,5 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
232232
preflight_cname_target=cd_settings.cf_cname_target
233233
if cd_settings.cf_zone_id
234234
else None,
235+
url_service=app.state.url_service,
235236
)

errors.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,6 @@ class InvalidDomainTransitionError(ValidationError):
126126
error_code = "invalid_domain_transition"
127127

128128

129-
class DomainDnsNotPropagatedError(ValidationError):
130-
"""Pre-register DNS check didn't see the routing CNAME yet."""
131-
132-
status_code = 422
133-
error_code = "domain_dns_not_propagated"
134-
135-
136129
class CloudflareAPIError(AppError):
137130
"""Cloudflare API call failed (4xx, 5xx, or network)."""
138131

infrastructure/cache/url_cache.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,19 @@ async def invalidate(self, short_code: str, domain: str) -> None:
9393
)
9494
except Exception as e:
9595
log.error("url_cache_invalidate_error", short_code=short_code, error=str(e))
96+
97+
async def invalidate_many(self, short_codes: list[str], domain: str) -> None:
98+
"""Bulk-invalidate cache entries for a list of aliases on one domain."""
99+
if not short_codes or self._redis is None:
100+
return
101+
keys = [self._key(c, domain) for c in short_codes]
102+
try:
103+
await self._redis.delete(*keys)
104+
log.info(
105+
"cache_invalidated_bulk",
106+
count=len(short_codes),
107+
domain=domain,
108+
reason="bulk_invalidation",
109+
)
110+
except Exception as e:
111+
log.error("url_cache_invalidate_many_error", domain=domain, error=str(e))

middleware/rate_limiter.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ class Limits:
7373
# URL management
7474
URL_MANAGE = "120 per minute; 2000 per day"
7575
URL_DELETE = "60 per minute; 1000 per day"
76+
URL_BULK_DELETE = "5 per minute; 50 per day"
77+
78+
# Custom domains
79+
DOMAIN_CREATE = "5 per hour"
80+
DOMAIN_VERIFY = "10 per minute"
81+
DOMAIN_READ = "60 per minute"
82+
DOMAIN_DELETE = "10 per minute"
7683

7784
# Dashboard — profile pictures
7885
PROFILE_PICTURE_SET = "10 per minute"

middleware/tenant.py

Lines changed: 136 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,35 @@
11
"""Resolve Host header → TenantInfo on every request.
22
33
Lands `request.state.tenant` for downstream handlers. Redirect route
4-
reads it to scope the URL lookup to the right tenant. Unknown public
5-
hosts get an HTML 404; internal/loopback hosts pass through with
6-
tenant=None so /health doesn't break.
4+
reads it to scope the URL lookup to the right tenant.
5+
6+
Routing policy for custom tenants is a strict allowlist:
7+
- `GET /<alias>` and `POST /<alias>/password` → redirect router
8+
- `GET /favicon.ico` → static router (generic favicon)
9+
- `GET /robots.txt` → inline `Disallow: /`
10+
- Everything else → 404
11+
12+
Operator surface (`/api/*`, `/dashboard/*`, `/auth/*`, `/oauth/*`, `/health`)
13+
and brand pages (`/about`, `/contact`, `/api-docs`, `/<alias>+`, `/report`)
14+
all 404 on custom tenants. Per-domain routing config (`root_redirect`,
15+
`not_found_redirect`, `custom_robots_txt`) lands in PR4.5.
16+
17+
System-default tenant behaves exactly as before — full app surface.
18+
19+
Every custom-tenant response carries `X-Robots-Tag: noindex, nofollow,
20+
noarchive` (post-handler). Combined with the disallow-all robots.txt, this
21+
keeps short links out of search indexes. Preview crawlers (Twitter/Slack/
22+
Discord/etc.) ignore these signals and continue to unfurl correctly.
723
"""
824

925
from __future__ import annotations
1026

27+
import re
1128
from urllib.parse import urlsplit
1229

1330
from fastapi import Request
1431
from starlette.middleware.base import BaseHTTPMiddleware
15-
from starlette.responses import HTMLResponse, Response
32+
from starlette.responses import HTMLResponse, PlainTextResponse, Response
1633

1734
from infrastructure.logging import get_logger
1835
from services.tenant_resolver.protocol import TenantInfo, TenantResolver
@@ -21,6 +38,55 @@
2138

2239
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "app"})
2340

41+
_NOT_FOUND_BODY = (
42+
"<!doctype html><html><head><title>404 — Not Found</title></head>"
43+
"<body><h1>404</h1><p>URL not found.</p></body></html>"
44+
)
45+
46+
_CUSTOM_TENANT_ROBOTS_BODY = "User-agent: *\nDisallow: /\n"
47+
_NOINDEX_HEADER = "noindex, nofollow, noarchive"
48+
49+
# Allowed exact paths on custom tenants (besides the alias pattern).
50+
_ALLOWED_EXACT_PATHS = frozenset({"/favicon.ico"})
51+
52+
# Reserved path prefixes — match these *before* the alias allowlist so
53+
# operator surface (`/dashboard/*`, `/api/*`, …) and brand pages
54+
# (`/about`, `/contact`, …) cannot be exposed through the alias namespace.
55+
# A path is reserved if it equals one of these strings exactly or starts
56+
# with one followed by `/`. Bare alias collisions (e.g. a customer creating
57+
# an alias literally named `dashboard`) are sacrificed for tenant isolation.
58+
_RESERVED_PREFIXES = (
59+
"/api",
60+
"/dashboard",
61+
"/auth",
62+
"/oauth",
63+
"/health",
64+
"/report",
65+
"/about",
66+
"/contact",
67+
"/privacy",
68+
"/api-docs",
69+
"/api-reference",
70+
)
71+
72+
# Alias paths allowed on custom tenants. Match `/<alias>` and
73+
# `/<alias>/password` only. Alias body is `[A-Za-z0-9_-]{3,16}` per
74+
# `shared.validators.validate_alias` plus the URL-safe slice of the emoji
75+
# range used in v2. Stats suffix (`+`) is intentionally NOT matched so
76+
# `/<alias>+` falls through to 404 — analytics surface stays on spoo.me.
77+
#
78+
# Emoji ranges: Misc Symbols & Pictographs (1F300-1F5FF), Emoticons
79+
# (1F600-1F64F), Transport & Map (1F680-1F6FF), Supplemental Symbols
80+
# (1F900-1F9FF), Extended-A (1FA70-1FAFF), and percent-encoded forms.
81+
_ALIAS_PATTERN = re.compile(
82+
r"^/"
83+
r"(?:[A-Za-z0-9_\-]"
84+
r"|[\U0001F300-\U0001F5FF\U0001F600-\U0001F64F"
85+
r"\U0001F680-\U0001F6FF\U0001F900-\U0001F9FF\U0001FA70-\U0001FAFF]"
86+
r"|%[0-9A-Fa-f]{2})+"
87+
r"(?:/password)?$"
88+
)
89+
2490

2591
def _normalise_host(raw: str) -> str:
2692
"""Lowercased, dot-stripped, port-stripped host. RFC 3986-safe for
@@ -34,8 +100,40 @@ def _normalise_host(raw: str) -> str:
34100
return (parsed or "").rstrip(".").lower()
35101

36102

103+
def _is_reserved_path(path: str) -> bool:
104+
for prefix in _RESERVED_PREFIXES:
105+
if path == prefix or path.startswith(prefix + "/"):
106+
return True
107+
return False
108+
109+
110+
def _is_allowed_on_custom_tenant(path: str, method: str) -> bool:
111+
"""Custom-tenant allowlist gate. Path-and-method check so disallowed
112+
methods on allowed paths (e.g. ``DELETE /<alias>``) return our 404
113+
instead of Starlette's 405, preserving the strict deny policy."""
114+
if path == "/":
115+
return False
116+
if _is_reserved_path(path):
117+
return False
118+
if path in _ALLOWED_EXACT_PATHS:
119+
# Static assets (favicon) are read-only.
120+
return method in {"GET", "HEAD"}
121+
if _ALIAS_PATTERN.match(path):
122+
# `/<alias>/password` is a form POST; everything else under the alias
123+
# namespace (the redirect) is GET/HEAD only.
124+
if path.endswith("/password"):
125+
return method == "POST"
126+
return method in {"GET", "HEAD"}
127+
return False
128+
129+
37130
class TenantMiddleware(BaseHTTPMiddleware):
38-
"""Populates request.state.tenant from the request Host header."""
131+
"""Populates request.state.tenant from the request Host header.
132+
133+
On custom tenants additionally enforces the allowlist routing policy
134+
documented at the top of this module and stamps the noindex header on
135+
every response.
136+
"""
39137

40138
async def dispatch(self, request: Request, call_next) -> Response:
41139
resolver: TenantResolver | None = getattr(
@@ -51,15 +149,41 @@ async def dispatch(self, request: Request, call_next) -> Response:
51149

52150
tenant: TenantInfo | None = await resolver.resolve(host)
53151
request.state.tenant = tenant
152+
54153
if tenant is None:
55154
log.info("tenant_unknown_host", host=host)
56-
# Static HTML 404 — browser-friendly, no template deps, no
57-
# tenancy details leaked.
58155
return HTMLResponse(_NOT_FOUND_BODY, status_code=404)
59-
return await call_next(request)
60156

157+
if tenant.is_system_default:
158+
return await call_next(request)
61159

62-
_NOT_FOUND_BODY = (
63-
"<!doctype html><html><head><title>404 — Not Found</title></head>"
64-
"<body><h1>404</h1><p>URL not found.</p></body></html>"
65-
)
160+
path = request.url.path
161+
162+
if path == "/robots.txt":
163+
if request.method not in {"GET", "HEAD"}:
164+
return HTMLResponse(
165+
_NOT_FOUND_BODY,
166+
status_code=404,
167+
headers={"X-Robots-Tag": _NOINDEX_HEADER},
168+
)
169+
return PlainTextResponse(
170+
_CUSTOM_TENANT_ROBOTS_BODY,
171+
headers={"X-Robots-Tag": _NOINDEX_HEADER},
172+
)
173+
174+
if not _is_allowed_on_custom_tenant(path, request.method):
175+
log.info(
176+
"tenant_path_denied",
177+
host=host,
178+
path=path,
179+
method=request.method,
180+
)
181+
return HTMLResponse(
182+
_NOT_FOUND_BODY,
183+
status_code=404,
184+
headers={"X-Robots-Tag": _NOINDEX_HEADER},
185+
)
186+
187+
response = await call_next(request)
188+
response.headers["X-Robots-Tag"] = _NOINDEX_HEADER
189+
return response

0 commit comments

Comments
 (0)