From 433308d6152b255ab8f31cf03dc50960ffc4f248 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Sun, 30 Aug 2026 17:27:40 -0700 Subject: [PATCH] Serve /api/ on piha.lc / beta.piha.li (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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/` still 302 - [ ] `/admin/` on beta.piha.li → 404 - [ ] Unit tests: `cd site && uv run python manage.py test` Reviewed-on: https://git.aimloperations.com/ai_ml_operations/url_shortening_service/pulls/8 --- .env.example | 6 +-- .env.prod.example | 10 +++-- API.md | 23 +++++----- Implementation.md | 64 ++++++++++++--------------- README.md | 36 +++++++-------- site/core/templates/core/landing.html | 4 +- site/links/auth.py | 2 +- site/links/middleware.py | 28 +++++++++--- site/links/tests.py | 53 ++++++++++------------ site/shortener/settings/base.py | 2 +- 10 files changed, 115 insertions(+), 113 deletions(-) diff --git a/.env.example b/.env.example index 3ff7550..deee64c 100644 --- a/.env.example +++ b/.env.example @@ -18,11 +18,11 @@ CONTACT_URL=https://aimloperations.com/contact SHORT_DOMAIN=localhost:8005 # Origin printed in minted short_url (phones hit this). Local: this machine. PUBLIC_SHORT_URL=http://127.0.0.1:8005 -# Host values that only serve GET / (no /api/). +# Host values that serve GET / and /api/ (Bearer). Same public hostname. SHORT_PUBLIC_HOSTS=piha.lc -# Host values that serve /api/ (Bearer required). May include a public DNS name. +# Extra Host values that also serve /api/ (localhost / docker). SHORT_API_HOSTS=localhost,127.0.0.1,0.0.0.0,web,url-shortener -# Django admin — keep local. Do not add the public API hostname. +# Django admin — keep local. Do not add the public short hostname. SHORT_ADMIN_HOSTS=localhost,127.0.0.1 # Named, rotatable tokens. This is what keeps /api/ closed on a public hostname. # Generate: python -c "import secrets; print(secrets.token_urlsafe(32))" diff --git a/.env.prod.example b/.env.prod.example index 151bed7..cc25490 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -12,8 +12,8 @@ DJANGO_ENV=prod DJANGO_DEBUG=false DJANGO_SECRET_KEY=replace-with-a-long-random-secret -# Public short host AND public API hostname (plus docker names if used). -DJANGO_ALLOWED_HOSTS=piha.lc,shortener.aimloperations.com,url-shortener,web +# Public short host (plus docker names if used). One hostname for mint + redirects. +DJANGO_ALLOWED_HOSTS=piha.lc,url-shortener,web # Shared external Postgres DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/url_shortener @@ -24,7 +24,7 @@ WEB_PORT=8005 SHORT_DOMAIN=piha.lc PUBLIC_SHORT_URL=https://piha.lc SHORT_PUBLIC_HOSTS=piha.lc -SHORT_API_HOSTS=shortener.aimloperations.com,url-shortener,web +SHORT_API_HOSTS=piha.lc,url-shortener,web SHORT_ADMIN_HOSTS=localhost,127.0.0.1 # Generate: python -c "import secrets; print(secrets.token_urlsafe(32))" # This token is the only thing that authorizes minting. Treat it as a secret. @@ -45,11 +45,13 @@ GUNICORN_BIND=0.0.0.0:8000 # DJANGO_ENV=beta # DJANGO_DEBUG=false # DJANGO_SECRET_KEY=replace-with-a-different-beta-secret -# DJANGO_ALLOWED_HOSTS=beta.piha.li,shortener-beta.aimloperations.com,url-shortener,web +# DJANGO_ALLOWED_HOSTS=beta.piha.li,url-shortener,web # DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/url_shortener_beta # WEB_PORT=8015 # SHORT_DOMAIN=beta.piha.li # PUBLIC_SHORT_URL=https://beta.piha.li # SHORT_PUBLIC_HOSTS=beta.piha.li +# SHORT_API_HOSTS=beta.piha.li,url-shortener,web +# SHORT_ADMIN_HOSTS=localhost,127.0.0.1 # SHORTENER_API_TOKENS=monica:replace-with-a-different-token # CLICK_IP_PEPPER=replace-with-a-different-pepper diff --git a/API.md b/API.md index 152aa14..0738abf 100644 --- a/API.md +++ b/API.md @@ -2,14 +2,13 @@ For other services (`monica_site`, later callers) that mint short links. -This service has two public hostnames. **Call the API host.** Never create links -on the short domain (`piha.lc` / `beta.piha.li`). That host -only serves `GET /` (landing) and `GET /` (302). `/api/` there is **404**. +This service has one public hostname. Call it for minting **and** for +redirects. Prod: `https://piha.lc`. Beta: `https://beta.piha.li`. | Host | Example | What you call | |------|---------|----------------| -| API | `https://shortener.aimloperations.com` | `POST/GET /api/links/` | -| Short | `https://piha.lc` (beta: `https://beta.piha.li`) | phones only — `GET /` | +| Prod | `https://piha.lc` | `POST /api/links/` and `GET /` | +| Beta | `https://beta.piha.li` | same | Local compose: API + redirects on `http://127.0.0.1:8005`. @@ -53,7 +52,7 @@ mint without it. allows `www.mkdrealtor.com`. `https` only. 5. Give the caller **only**: - - API origin (`SHORTENER_BASE_URL`) + - Origin (`SHORTENER_BASE_URL` — `https://piha.lc` or `https://beta.piha.li`) - The full token string `name:secret` (they send it as Bearer) Never put the token in git, logs, or the short URL. @@ -66,11 +65,11 @@ Add to that app’s env (not this repo): ```text # Prod -SHORTENER_BASE_URL=https://shortener.aimloperations.com +SHORTENER_BASE_URL=https://piha.lc SHORTENER_API_TOKEN=monica: -# Beta (when that host exists) -# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com +# Beta +# SHORTENER_BASE_URL=https://beta.piha.li # SHORTENER_API_TOKEN=monica: # Local (this service via compose) @@ -156,8 +155,8 @@ Content-Type: application/json } ``` -`short_url` is built from this service’s `PUBLIC_SHORT_URL` (the domain phones -hit). It is **not** the API host. Put `short_url` in SMS / email as-is. +`short_url` is built from this service’s `PUBLIC_SHORT_URL` (same origin you +called). Put `short_url` in SMS / email as-is. ### Idempotency `200` @@ -286,4 +285,4 @@ def shorten(target_url: str, *, title: str = "", external_ref: str = "") -> str: ``` Use `short_url` in the message body. On `401`/`503`, fail the send — do not -fall back to pasting the API host into SMS. +fall back to pasting a long URL unless that is your explicit fallback. diff --git a/Implementation.md b/Implementation.md index b2ee51c..fe97705 100644 --- a/Implementation.md +++ b/Implementation.md @@ -15,7 +15,7 @@ This document is the build spec. Implement in this repo. Do not fold the shorten - Mint a short HTTPS URL that 302s to a long HTTPS URL. - Public `GET /` so SMS recipients can tap the link with no token. -- Create / list / disable **only** via a Bearer-authenticated API (own hostname; may be public DNS). +- Create / list / disable **only** via a Bearer-authenticated API on the public short host. - Named, rotatable tokens so `monica_site` can be revoked without rotating every caller. - Target-host allowlist so a stolen token cannot mint open redirects off the short domain. - Click counts for campaign reporting. @@ -38,7 +38,7 @@ Two surfaces. Mixing them is the failure mode. | Surface | Who | Auth | Network | |---------|-----|------|---------| | `GET /` | anyone with the SMS | **none** | public short hostname (NPM + TLS) | -| `/api/links/` (all methods) | `monica_site` and other trusted callers | **Bearer** | own hostname — **may be public DNS**. Token is the lock. | +| `/api/links/` (all methods) | `monica_site` and other trusted callers | **Bearer** | same public hostname. Token is the lock. | ### 2.1 Public redirect @@ -60,30 +60,24 @@ working. `DJANGO_SECRET_KEY` or any `monica_site` webhook secret. - Do not log the `Authorization` header (Gunicorn/NPM access logs). Strip or disable it. -### 2.3 Two public hostnames (Bearer is the lock on /api/) +### 2.3 One public hostname (Bearer is the lock on /api/) -`/api/links/` may have its own public DNS record. Anyone who finds that URL still +`/api/links/` lives on `piha.lc` / `beta.piha.li`. Anyone who finds that URL still cannot mint links without a configured Bearer token. Empty `SHORTENER_API_TOKENS` → **503**. Missing/wrong token → **401**. Do not add CORS `*` (server-to-server only). ``` Internet │ - ├─ NPM: SHORT_DOMAIN (piha.lc / beta.piha.li) - │ location ~*^/[a-z0-9]{4,8}$ → gunicorn (redirects only) - │ /api/* → 404 / drop - │ /admin/ → 404 / drop - │ - └─ NPM: API hostname (e.g. shortener.aimloperations.com) - /api/links/ → gunicorn (Bearer required) - /admin/ → 404 / drop + └─ NPM: SHORT_DOMAIN (piha.lc / beta.piha.li) + GET / → gunicorn (redirects) + /api/links/ → gunicorn (Bearer required) + /admin/ → 404 (Django) ``` -- Public NPM for `SHORT_DOMAIN` must not proxy `/api/` or `/admin/`. -- Public NPM for the API hostname proxies `/api/` only. Do not expose `/admin/`. -- `monica_site` calls `SHORTENER_BASE_URL` (the API hostname), never the short - hostname, to create links. -- Django admin stays on `SHORT_ADMIN_HOSTS` (localhost). Not on the public API host. +- One NPM proxy host. `location /` → gunicorn. Django 404s `/admin/` and `/debug/`. +- `monica_site` calls `SHORTENER_BASE_URL` (`https://piha.lc` or `https://beta.piha.li`). +- Django admin stays on `SHORT_ADMIN_HOSTS` (localhost). Not on the public host. ### 2.4 Target allowlist @@ -99,10 +93,11 @@ On create: Even if NPM is misconfigured, the Django process must refuse the wrong surface: -- `request.get_host()` in `SHORT_PUBLIC_HOSTS` → redirect view only. `/api/` → 404. -- `request.get_host()` in `SHORT_API_HOSTS` → `/api/` (still Bearer). May be public DNS. +- `request.get_host()` in `SHORT_PUBLIC_HOSTS` → redirects **and** `/api/` (Bearer). +- `request.get_host()` in `SHORT_API_HOSTS` → `/api/` (still Bearer). Extra names + (localhost, docker) only. - `request.get_host()` in `SHORT_ADMIN_HOSTS` → `/admin/` (localhost only by default). -- `/healthz/` allowed on both public hosts. No secrets in the body. +- `/healthz/` allowed on both. No secrets in the body. --- @@ -120,7 +115,8 @@ Phone SMS: https://mkd.to/a3k9 │ Host header? ├─ public short host → GET / 302 + Click row - └─ API host (own DNS) → /api/links/ (Bearer) + │ → /api/links/ (Bearer) + └─ localhost / docker → /api/links/ (Bearer) │ ▼ Postgres (own DATABASE_URL) @@ -191,7 +187,7 @@ url_shortening_service/ services.py urls.py tests.py - admin.py # optional; only reachable on API host if enabled + admin.py # optional; localhost only core/ views.py # healthz ``` @@ -353,12 +349,12 @@ Reject codes that do not match `^[a-z0-9]{4,8}$` with 404 (no extra work). | `DJANGO_ENV` | `dev` / `beta` / `prod` | | `DJANGO_SECRET_KEY` | Django signing; **not** an API token | | `DJANGO_DEBUG` | false in prod | -| `DJANGO_ALLOWED_HOSTS` | public short host **and** API hostname | +| `DJANGO_ALLOWED_HOSTS` | public short host (plus docker names) | | `DATABASE_URL` | Postgres; empty → SQLite | | `SHORT_DOMAIN` | public hostname phones use (`piha.lc` prod, `beta.piha.li` beta) | | `PUBLIC_SHORT_URL` | origin for minted URLs, e.g. `https://piha.lc` (no trailing slash) | -| `SHORT_PUBLIC_HOSTS` | comma list; Host values that only serve redirects | -| `SHORT_API_HOSTS` | comma list; Host values that serve `/api/` (public API DNS and/or docker name) | +| `SHORT_PUBLIC_HOSTS` | comma list; Host values that serve redirects **and** `/api/` | +| `SHORT_API_HOSTS` | comma list; extra Host values that serve `/api/` (localhost / docker) | | `SHORT_ADMIN_HOSTS` | comma list; Host values that serve `/admin/` (default localhost only) | | `SHORTENER_API_TOKENS` | `name:secret,name:secret` — required for API | | `SHORT_ALLOWED_HOSTS` | allowlist for `target_url` hosts | @@ -373,7 +369,7 @@ Reject codes that do not match `^[a-z0-9]{4,8}$` with 404 (no extra work). ### Caller (`monica_site`) — later, other repo ```text -SHORTENER_BASE_URL=https://shortener.aimloperations.com +SHORTENER_BASE_URL=https://piha.lc SHORTENER_API_TOKEN=monica: ``` @@ -403,7 +399,7 @@ Same pattern as `monica_site`: - `web`: build `.`, mount `./site`, `DJANGO_ENV=dev` → runserver - **Do not** publish the host port to the LAN unless you are testing redirects. Prefer `127.0.0.1:8005:8000` (container still listens on 8000). -- Internal API hostname: service name `web` (or `url-shortener`). +- Internal hostname: service name `web` (or `url-shortener`). ### `docker-compose.prod.yml` @@ -424,12 +420,10 @@ Wait for DB → `migrate --noinput` → if `DJANGO_ENV=dev` then `runserver`, el When the domains exist: 1. Public NPM proxy host = `SHORT_DOMAIN` → this container’s `WEB_PORT`. -2. Proxy `/` (landing) and the code regex. `/api/`, `/admin/`, `/debug/` → 404. +2. Proxy `/` (landing), the code regex, and `/api/`. Django 404s `/admin/` and `/debug/`. 3. TLS like other apps. -4. Add `SHORT_DOMAIN` to `DJANGO_ALLOWED_HOSTS` and `SHORT_PUBLIC_HOSTS`. +4. Add `SHORT_DOMAIN` to `DJANGO_ALLOWED_HOSTS`, `SHORT_PUBLIC_HOSTS`, and `SHORT_API_HOSTS`. 5. `PUBLIC_SHORT_URL=https://`. -6. Second NPM proxy host = API hostname → same container. Proxy `/api/` only. -7. Add the API hostname to `DJANGO_ALLOWED_HOSTS` and `SHORT_API_HOSTS`. CI (same split as `monica_site`, default branch `master`): @@ -447,15 +441,15 @@ Django `TestCase` / `SimpleTestCase`. No live network. - Auth: missing Bearer → 401; wrong token → 401; empty `SHORTENER_API_TOKENS` → 503; matching named token → 201. -- Host split: request to short host `/api/links/` → 404 even with valid Bearer. - Public API host without Bearer → 401; valid Bearer → 201. `/admin/` 404 on API host. +- Host split: request to short host `/api/links/` without Bearer → 401; valid Bearer → 201. + Host not in public/API lists → 404. `/admin/` 404 on the public host. - Allowlist: `https://mkdrealtor.com/x` ok; `http://…` 400; `https://evil.com` 400; `javascript:alert(1)` 400. - Redirect: active code → 302 to target; inactive/expired/unknown → 404. - Redirect is 302, not 301. - Click row created and `click_count` incremented. - Idempotent create with same `target_url` + `external_ref`. -- `short_url` uses `PUBLIC_SHORT_URL`, not the API Host header. +- `short_url` uses `PUBLIC_SHORT_URL`. - Code charset: generated codes only use the unambiguous alphabet. --- @@ -488,7 +482,7 @@ cd site && uv run python manage.py migrate uv run python manage.py runserver ``` -Mint (API host / localhost): +Mint (same host / localhost): ```bash curl -sS -X POST http://127.0.0.1:8005/api/links/ \ diff --git a/README.md b/README.md index 607d635..61e2099 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # url_shortening_service Django 6 URL shortener. `monica_site` (and other trusted callers) mint links over -Bearer auth on an **API hostname** (may be public DNS). SMS recipients hit -`GET /` on the **short** domain and get a 302 to the long HTTPS URL. +Bearer auth on the **same** public host phones use. SMS recipients hit +`GET /` on `piha.lc` (prod) / `beta.piha.li` (beta) and get a 302 to the +long HTTPS URL. This service is standalone. Do not fold it into `monica_site`. @@ -10,17 +11,16 @@ This service is standalone. Do not fold it into `monica_site`. | Who | Path | Auth | Host | |-----|------|------|------| -| Phone / public internet | `GET /` | none | `SHORT_DOMAIN` (NPM + TLS) | -| Anyone | `GET /` | none | landing page on the short domain | -| `monica_site` | `/api/links/` | `Authorization: Bearer name:secret` | own DNS / NPM host — **not** the short domain | +| Phone / public internet | `GET /` | none | `SHORT_DOMAIN` (`piha.lc` / `beta.piha.li`) | +| Anyone | `GET /` | none | landing page on that same host | +| `monica_site` | `/api/links/` | `Authorization: Bearer name:secret` | same host | -The API may be on the public internet. It is not open: every `/api/` request needs a +`/api/` is on the public internet. It is not open: every `/api/` request needs a named Bearer token. No token / wrong token → **401**. No tokens configured → **503**. `GET /` never requires a token. -`/api/` and `/admin/` are 404 on the short domain. `/admin/` is also 404 on the -public API hostname (localhost only). `GET /debug/` is a mint form when -`DEBUG=true` and never on the short domain. +`/admin/` is 404 on the public host (localhost only). `GET /debug/` is a mint form +when `DEBUG=true` and never on the public short host. ## Local run @@ -63,11 +63,11 @@ Deploy calls `server-infra/scripts/deploy.sh --app url_shortening_service`. Need ## Use case 1. Your site POSTs to this service with a Bearer token and a long HTTPS URL. -2. Response `201` includes `short_url` built from `PUBLIC_SHORT_URL` (not the API Host). +2. Response `201` includes `short_url` built from `PUBLIC_SHORT_URL`. 3. A person on the public internet opens that short URL. 4. This service 302s them to the long URL and increments `click_count`. -Mint (API host / localhost): +Mint (same host / localhost): ```bash curl -sS -X POST http://127.0.0.1:8005/api/links/ \ @@ -89,7 +89,7 @@ Caller integration (onboard + `/api/links/`): **[API.md](API.md)**. ## Caller contract (`monica_site`, other repo) ```text -SHORTENER_BASE_URL=https://shortener.aimloperations.com +SHORTENER_BASE_URL=https://piha.lc SHORTENER_API_TOKEN=monica: ``` @@ -105,7 +105,7 @@ Content-Type: application/json (exact or suffix, e.g. `mkdrealtor.com`). Same `target_url` + `external_ref` + still-active link returns `200` with the existing row instead of a new code. -Do not call the public short hostname to create links. +Do not put the Bearer token on `GET /`. ## Environment @@ -123,9 +123,9 @@ short URL, logs, or git. ## Deploy notes -Two NPM hosts, same container `WEB_PORT` (default 8005 prod / 8015 beta): +One NPM host, container `WEB_PORT` (default 8005 prod / 8015 beta): -1. `SHORT_DOMAIN` (`piha.lc` prod / `beta.piha.li` beta) — `/` landing + `GET /[a-z0-9]{4,8}`. - Drop `/api/`, `/admin/`, `/debug/`. -2. API hostname — proxy `/api/` only. Drop `/admin/`. Add that Host to - `DJANGO_ALLOWED_HOSTS` and `SHORT_API_HOSTS`. +1. `SHORT_DOMAIN` (`piha.lc` prod / `beta.piha.li` beta) — `/` landing, + `GET /[a-z0-9]{4,8}`, and `/api/` (Bearer). Django 404s `/admin/` and `/debug/` + on this host. Add it to `DJANGO_ALLOWED_HOSTS`, `SHORT_PUBLIC_HOSTS`, and + `SHORT_API_HOSTS`. diff --git a/site/core/templates/core/landing.html b/site/core/templates/core/landing.html index 4a58d47..b9e5078 100644 --- a/site/core/templates/core/landing.html +++ b/site/core/templates/core/landing.html @@ -14,7 +14,7 @@ -
+

Why Piha?

@@ -27,7 +27,7 @@

-
+

A shortener you can hand to SMS

diff --git a/site/links/auth.py b/site/links/auth.py index 61de518..aa3c677 100644 --- a/site/links/auth.py +++ b/site/links/auth.py @@ -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 diff --git a/site/links/middleware.py b/site/links/middleware.py index 7e91eda..dea0237 100644 --- a/site/links/middleware.py +++ b/site/links/middleware.py @@ -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 / 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) diff --git a/site/links/tests.py b/site/links/tests.py index 6511ebf..cda0fe3 100644 --- a/site/links/tests.py +++ b/site/links/tests.py @@ -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): diff --git a/site/shortener/settings/base.py b/site/shortener/settings/base.py index 7d7be7c..7706080 100644 --- a/site/shortener/settings/base.py +++ b/site/shortener/settings/base.py @@ -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(