Serve /api/ on piha.lc / beta.piha.li (#8)
Deploy Beta / unit-tests (push) Successful in 4s
Deploy Beta / docker (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 1m3s

## Summary
- Serve `/api/` on `piha.lc` / `beta.piha.li` (Bearer still required).
- Drop `shortener.aimloperations.com` / `shortener-beta.aimloperations.com` from env examples and caller docs.
- `/admin/` stays 404 on the public host.

Closes #7.

## Test plan
- [ ] `POST https://beta.piha.li/api/links/` with valid Bearer → 201
- [ ] Same without Bearer → 401
- [ ] `GET https://beta.piha.li/<code>` still 302
- [ ] `/admin/` on beta.piha.li → 404
- [ ] Unit tests: `cd site && uv run python manage.py test`

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-08-30 17:27:40 -07:00
parent e879d4888f
commit 433308d615
10 changed files with 115 additions and 113 deletions
+2 -2
View File
@@ -14,7 +14,7 @@
</div>
</div>
<div class="section" style="background: var(--surface-color);">
<div class="section">
<div class="container">
<h2 class="section-title">Why Piha?</h2>
<p style="text-align: center; max-width: 820px; margin: 0 auto 1.5rem; color: var(--text-muted); font-size: 1.1rem;">
@@ -27,7 +27,7 @@
</div>
</div>
<div class="section">
<div class="section" style="background: var(--surface-color);">
<div class="container">
<h2 class="section-title">A shortener you can hand to SMS</h2>
<p style="text-align: center; max-width: 820px; margin: 0 auto; color: var(--text-muted); font-size: 1.1rem;">
+1 -1
View File
@@ -1,4 +1,4 @@
"""Bearer token auth for /api/. The lock that keeps a public API host closed."""
"""Bearer token auth for /api/. The lock that keeps minting closed on the public host."""
from __future__ import annotations
+21 -7
View File
@@ -1,10 +1,14 @@
"""Keep the short domain and Django admin off the public API hostname."""
"""Host split: public short domain serves redirects + /api/; admin stays local."""
from __future__ import annotations
import logging
from django.conf import settings
from django.http import Http404, HttpRequest
logger = logging.getLogger(__name__)
def _normalize_host(host: str) -> str:
return host.split(":")[0].lower().rstrip(".")
@@ -33,11 +37,16 @@ def is_admin_host(host: str) -> bool:
return _host_in(host, list(getattr(settings, "SHORT_ADMIN_HOSTS", []) or []))
class HostSplitMiddleware:
"""Short host = redirects only. API host = /api/ (Bearer). Admin = local only.
def serves_api(host: str) -> bool:
"""Short domain and extra API hosts both serve /api/ (Bearer)."""
return is_api_host(host) or is_public_host(host)
A public DNS name may be listed in SHORT_API_HOSTS. Auth, not the network,
keeps /api/ closed: missing/wrong Bearer is 401; empty token list is 503.
class HostSplitMiddleware:
"""One public host: GET /<code> and /api/ (Bearer). Admin = local only.
Auth, not a second DNS name, keeps /api/ closed: missing/wrong Bearer is 401;
empty token list is 503.
"""
def __init__(self, get_response):
@@ -61,8 +70,13 @@ class HostSplitMiddleware:
return self.get_response(request)
if path.startswith("/api/"):
# Short redirect hostname never serves the API, even if mis-listed.
if is_public_host(host) or not is_api_host(host):
if not serves_api(host):
logger.warning(
"blocked /api/ host=%s public=%s api=%s",
host,
is_public_host(host),
is_api_host(host),
)
raise Http404()
return self.get_response(request)
+23 -30
View File
@@ -21,7 +21,7 @@ SETTINGS = dict(
SHORT_DOMAIN="piha.lc",
PUBLIC_SHORT_URL="https://piha.lc",
SHORT_PUBLIC_HOSTS=["piha.lc"],
SHORT_API_HOSTS=["testserver", "localhost", "127.0.0.1", "shortener.example.com"],
SHORT_API_HOSTS=["testserver", "localhost", "127.0.0.1", "piha.lc"],
SHORT_ADMIN_HOSTS=["localhost", "127.0.0.1"],
SHORT_ALLOWED_HOSTS=["mkdrealtor.com"],
CLICK_IP_PEPPER="test-pepper-not-the-secret-key",
@@ -88,7 +88,7 @@ class AuthTests(TestCase):
@override_settings(**SETTINGS)
class HostSplitTests(TestCase):
def test_public_host_api_404_even_with_bearer(self):
def test_short_host_with_bearer_201(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
@@ -96,63 +96,56 @@ class HostSplitTests(TestCase):
HTTP_AUTHORIZATION=AUTH,
HTTP_HOST="piha.lc",
)
self.assertEqual(response.status_code, 404)
@override_settings(
SHORT_API_HOSTS=[
"testserver",
"localhost",
"127.0.0.1",
"shortener.example.com",
"piha.lc",
]
)
def test_short_host_never_serves_api_even_if_also_listed_as_api(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
HTTP_HOST="piha.lc",
)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.status_code, 201)
def test_public_host_admin_404(self):
response = self.client.get("/admin/", HTTP_HOST="piha.lc")
self.assertEqual(response.status_code, 404)
def test_public_api_host_without_bearer_401(self):
def test_short_host_without_bearer_401(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_HOST="shortener.example.com",
HTTP_HOST="piha.lc",
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response["WWW-Authenticate"], "Bearer")
def test_public_api_host_wrong_token_401(self):
def test_short_host_wrong_token_401(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer monica:wrong-secret",
HTTP_HOST="shortener.example.com",
HTTP_HOST="piha.lc",
)
self.assertEqual(response.status_code, 401)
def test_public_api_host_valid_bearer_201(self):
@override_settings(
SHORT_API_HOSTS=["testserver", "localhost", "127.0.0.1"],
)
def test_short_host_serves_api_even_if_not_in_api_hosts(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
HTTP_HOST="shortener.example.com",
HTTP_HOST="piha.lc",
)
self.assertEqual(response.status_code, 201)
def test_public_api_host_admin_404(self):
response = self.client.get("/admin/", HTTP_HOST="shortener.example.com")
@override_settings(
ALLOWED_HOSTS=[*SETTINGS["ALLOWED_HOSTS"], "other.example.com"]
)
def test_allowed_host_not_public_or_api_404(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
HTTP_HOST="other.example.com",
)
self.assertEqual(response.status_code, 404)
def test_healthz_on_public_and_api(self):
+1 -1
View File
@@ -184,7 +184,7 @@ SHORT_API_HOSTS = env_list(
"SHORT_API_HOSTS",
"localhost,127.0.0.1,0.0.0.0,testserver,web,url-shortener",
)
# Django admin — local/dev only. Never put the public API hostname here.
# Django admin — local/dev only. Never put the public short hostname here.
SHORT_ADMIN_HOSTS = env_list("SHORT_ADMIN_HOSTS", "localhost,127.0.0.1")
SHORTENER_API_TOKENS = parse_api_tokens(env("SHORTENER_API_TOKENS", "") or "")
SHORT_ALLOWED_HOSTS = env_list(