Skip to content

Commit 83809e4

Browse files
committed
feat(custom-domains): public API + dashboard + tenant routing policy
Brings custom domains from a service-layer-only feature to user-facing. Owners can now register a domain (e.g. links.acme.com), publish DNS records returned by the API, verify, and serve short links under their own brand. API POST /api/v1/custom-domains register POST /api/v1/custom-domains/{id}/verify trigger verification GET /api/v1/custom-domains list owned (paginated) DELETE /api/v1/custom-domains/{id} revoke (?cascade=true also bulk-deletes URLs on the domain) DELETE /api/v1/urls?domain=<fqdn> bulk URL delete on owned domain POST /api/v1/shorten accepts an optional `domain` field; ownership + ACTIVE-status enforced at the route layer. GET /api/v1/urls accepts a `?domain=` filter and UrlListItem now exposes `domain`. Tenant routing Custom hostnames serve a strict allowlist: `/<alias>`, `/<alias>/password`, `/favicon.ico`, `/robots.txt`. Everything else (operator surface like /api, /dashboard, /auth, /oauth; brand pages like /about, /contact; the stats suffix `/<alias>+`; /report; /health) returns 404 so customer-branded hosts don't expose the canonical app surface. Per-tenant `/robots.txt` is served inline as `Disallow: /`, and every custom-tenant response is stamped with `X-Robots-Tag: noindex, nofollow, noarchive`. The existing system-default `/<alias>` redirect header gained `noarchive` alongside this. Auth + scopes New API key scopes `domains:manage` and `domains:read`. New `require_scopes_verified` dependency combines scope auth with the email-verified gate so both JWT and API key callers can create. Response shape CustomDomainResponse now carries structured `dns_records` (each with type/name/value/purpose) plus `setup_notes` for human-readable warnings (e.g. "Cloudflare DNS detected — set DNS-only / grey cloud"). Dropped the old free-form `setup_instructions` string and the internal `verification_token` field (no longer surfaced over the wire). Service / repository CustomDomainService gains `assert_owned`, `assert_owned_and_active` helpers and `delete(*, cascade=False)` returning `(doc, urls_deleted)`. Cascade order: REVOKE first, bulk delete URLs second, edge eviction third, cache invalidate last. Partial failures swallow + log; a future garbage-collection worker reaps orphan URLs. UrlService.create accepts an opaque `domain=`; alias-uniqueness now scopes through `(domain, alias)`. `delete_all_by_domain` refuses the system-default namespace defensively. Repository: list_aliases_by_owner_and_domain + delete_many_by_owner_and_domain. Cache: invalidate_many for bulk cleanup. Rate limits Slowapi limits on every new route (5/hr CREATE, 10/min VERIFY/DELETE, 60/min READ, 5/min bulk delete). Service-side per-user Mongo + Redis quotas still apply on top — layered defense. Dashboard New /dashboard/domains page with a sidebar nav entry. Markup matches the existing keys/links pattern: external CSS+JS, `<template>` row cloning, status badges via design tokens, server shell + client fetch hydration. Delete modal fetches the current URL count and offers the caller a choice between orphaning the URLs or cascade-deleting them. Tests 74 new test cases across DTOs, service helpers, route happy/sad paths, URL repository bulk methods, cache invalidate_many, and the tenant middleware routing matrix (including robots.txt, favicon, operator blocklist, stats-suffix block). Full suite: 1619 passing.
1 parent d701259 commit 83809e4

37 files changed

Lines changed: 2806 additions & 100 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
)

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: 120 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,30 @@ 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) -> bool:
111+
if path == "/":
112+
return False
113+
if _is_reserved_path(path):
114+
return False
115+
if path in _ALLOWED_EXACT_PATHS:
116+
return True
117+
return bool(_ALIAS_PATTERN.match(path))
118+
119+
37120
class TenantMiddleware(BaseHTTPMiddleware):
38-
"""Populates request.state.tenant from the request Host header."""
121+
"""Populates request.state.tenant from the request Host header.
122+
123+
On custom tenants additionally enforces the allowlist routing policy
124+
documented at the top of this module and stamps the noindex header on
125+
every response.
126+
"""
39127

40128
async def dispatch(self, request: Request, call_next) -> Response:
41129
resolver: TenantResolver | None = getattr(
@@ -51,15 +139,35 @@ async def dispatch(self, request: Request, call_next) -> Response:
51139

52140
tenant: TenantInfo | None = await resolver.resolve(host)
53141
request.state.tenant = tenant
142+
54143
if tenant is None:
55144
log.info("tenant_unknown_host", host=host)
56-
# Static HTML 404 — browser-friendly, no template deps, no
57-
# tenancy details leaked.
58145
return HTMLResponse(_NOT_FOUND_BODY, status_code=404)
59-
return await call_next(request)
60146

147+
if tenant.is_system_default:
148+
return await call_next(request)
61149

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-
)
150+
path = request.url.path
151+
152+
if path == "/robots.txt":
153+
return PlainTextResponse(
154+
_CUSTOM_TENANT_ROBOTS_BODY,
155+
headers={"X-Robots-Tag": _NOINDEX_HEADER},
156+
)
157+
158+
if not _is_allowed_on_custom_tenant(path):
159+
log.info(
160+
"tenant_path_denied",
161+
host=host,
162+
path=path,
163+
method=request.method,
164+
)
165+
return HTMLResponse(
166+
_NOT_FOUND_BODY,
167+
status_code=404,
168+
headers={"X-Robots-Tag": _NOINDEX_HEADER},
169+
)
170+
171+
response = await call_next(request)
172+
response.headers["X-Robots-Tag"] = _NOINDEX_HEADER
173+
return response

repositories/url_repository.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,53 @@ async def delete(self, url_id: ObjectId) -> bool:
5353
"""Hard-delete a URL document. Returns True if a document was deleted."""
5454
return await self._delete({"_id": url_id})
5555

56+
async def list_aliases_by_owner_and_domain(
57+
self, owner_id: ObjectId, domain: str
58+
) -> list[str]:
59+
"""Return all aliases owned by *owner_id* under *domain*.
60+
61+
Used by bulk-delete to drive cache invalidation. Two-step (list then
62+
delete) trades atomicity for explicit cache cleanup — a cache miss
63+
post-delete is correct behavior anyway.
64+
"""
65+
try:
66+
cursor = self._col.find(
67+
{"owner_id": owner_id, "domain": domain},
68+
projection={"alias": 1, "_id": 0},
69+
)
70+
docs = await cursor.to_list(length=None)
71+
return [d["alias"] for d in docs if "alias" in d]
72+
except PyMongoError as exc:
73+
log.error(
74+
"repo_list_aliases_failed",
75+
collection=self._collection_name,
76+
error=str(exc),
77+
)
78+
raise
79+
80+
async def delete_many_by_owner_and_domain(
81+
self, owner_id: ObjectId, domain: str
82+
) -> int:
83+
"""Bulk-delete all URLs owned by *owner_id* under *domain*.
84+
85+
Both filters required defensively — a missing or empty arg here would
86+
silently delete more than intended.
87+
"""
88+
if not owner_id or not domain:
89+
raise ValueError("owner_id and domain are both required for bulk delete")
90+
try:
91+
result = await self._col.delete_many(
92+
{"owner_id": owner_id, "domain": domain}
93+
)
94+
return int(result.deleted_count or 0)
95+
except PyMongoError as exc:
96+
log.error(
97+
"repo_delete_many_failed",
98+
collection=self._collection_name,
99+
error=str(exc),
100+
)
101+
raise
102+
56103
async def check_alias_exists(self, alias: str, domain: str) -> bool:
57104
"""Return True if the alias is taken under the given domain namespace."""
58105
doc = await self._find_one_raw({"alias": alias, "domain": domain}, {"_id": 1})

0 commit comments

Comments
 (0)