Serve /api/ on the public short host so callers use piha.lc / beta.piha.li.
CI / test (pull_request) Successful in 4s
CI / test (pull_request) Successful in 4s
Closes #7.
This commit is contained in:
+29
-35
@@ -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)
|
||||
/api/links/ → gunicorn (Bearer required)
|
||||
/admin/ → 404 / drop
|
||||
└─ NPM: SHORT_DOMAIN (piha.lc / beta.piha.li)
|
||||
GET /<code> → 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 /<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/ \
|
||||
|
||||
Reference in New Issue
Block a user