Serve /api/ on piha.lc / beta.piha.li (#8)
## 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:
+3
-3
@@ -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 /<code> (no /api/).
|
||||
# Host values that serve GET /<code> 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))"
|
||||
|
||||
+6
-4
@@ -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
|
||||
|
||||
@@ -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 /<code>` (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 /<code>` |
|
||||
| Prod | `https://piha.lc` | `POST /api/links/` and `GET /<code>` |
|
||||
| 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:<same-secret>
|
||||
|
||||
# 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:<beta-secret>
|
||||
|
||||
# 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.
|
||||
|
||||
+28
-34
@@ -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 /<code>` 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 /<code>` | 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)
|
||||
└─ NPM: SHORT_DOMAIN (piha.lc / beta.piha.li)
|
||||
GET /<code> → gunicorn (redirects)
|
||||
/api/links/ → gunicorn (Bearer required)
|
||||
/admin/ → 404 / drop
|
||||
/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 /<code> 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:<same-secret>
|
||||
```
|
||||
|
||||
@@ -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://<that-domain>`.
|
||||
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/ \
|
||||
|
||||
@@ -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 /<code>` 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 /<code>` 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 /<code>` | 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 /<code>` | 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 /<code>` 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:<same-secret-as-SHORTENER_API_TOKENS>
|
||||
```
|
||||
|
||||
@@ -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 /<code>`.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user