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:
+3
-3
@@ -18,11 +18,11 @@ CONTACT_URL=https://aimloperations.com/contact
|
|||||||
SHORT_DOMAIN=localhost:8005
|
SHORT_DOMAIN=localhost:8005
|
||||||
# Origin printed in minted short_url (phones hit this). Local: this machine.
|
# Origin printed in minted short_url (phones hit this). Local: this machine.
|
||||||
PUBLIC_SHORT_URL=http://127.0.0.1:8005
|
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
|
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
|
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
|
SHORT_ADMIN_HOSTS=localhost,127.0.0.1
|
||||||
# Named, rotatable tokens. This is what keeps /api/ closed on a public hostname.
|
# Named, rotatable tokens. This is what keeps /api/ closed on a public hostname.
|
||||||
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
|||||||
+6
-4
@@ -12,8 +12,8 @@
|
|||||||
DJANGO_ENV=prod
|
DJANGO_ENV=prod
|
||||||
DJANGO_DEBUG=false
|
DJANGO_DEBUG=false
|
||||||
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
||||||
# Public short host AND public API hostname (plus docker names if used).
|
# Public short host (plus docker names if used). One hostname for mint + redirects.
|
||||||
DJANGO_ALLOWED_HOSTS=piha.lc,shortener.aimloperations.com,url-shortener,web
|
DJANGO_ALLOWED_HOSTS=piha.lc,url-shortener,web
|
||||||
|
|
||||||
# Shared external Postgres
|
# Shared external Postgres
|
||||||
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/url_shortener
|
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
|
SHORT_DOMAIN=piha.lc
|
||||||
PUBLIC_SHORT_URL=https://piha.lc
|
PUBLIC_SHORT_URL=https://piha.lc
|
||||||
SHORT_PUBLIC_HOSTS=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
|
SHORT_ADMIN_HOSTS=localhost,127.0.0.1
|
||||||
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
# This token is the only thing that authorizes minting. Treat it as a secret.
|
# 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_ENV=beta
|
||||||
# DJANGO_DEBUG=false
|
# DJANGO_DEBUG=false
|
||||||
# DJANGO_SECRET_KEY=replace-with-a-different-beta-secret
|
# 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
|
# DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/url_shortener_beta
|
||||||
# WEB_PORT=8015
|
# WEB_PORT=8015
|
||||||
# SHORT_DOMAIN=beta.piha.li
|
# SHORT_DOMAIN=beta.piha.li
|
||||||
# PUBLIC_SHORT_URL=https://beta.piha.li
|
# PUBLIC_SHORT_URL=https://beta.piha.li
|
||||||
# SHORT_PUBLIC_HOSTS=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
|
# SHORTENER_API_TOKENS=monica:replace-with-a-different-token
|
||||||
# CLICK_IP_PEPPER=replace-with-a-different-pepper
|
# CLICK_IP_PEPPER=replace-with-a-different-pepper
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
For other services (`monica_site`, later callers) that mint short links.
|
For other services (`monica_site`, later callers) that mint short links.
|
||||||
|
|
||||||
This service has two public hostnames. **Call the API host.** Never create links
|
This service has one public hostname. Call it for minting **and** for
|
||||||
on the short domain (`piha.lc` / `beta.piha.li`). That host
|
redirects. Prod: `https://piha.lc`. Beta: `https://beta.piha.li`.
|
||||||
only serves `GET /` (landing) and `GET /<code>` (302). `/api/` there is **404**.
|
|
||||||
|
|
||||||
| Host | Example | What you call |
|
| Host | Example | What you call |
|
||||||
|------|---------|----------------|
|
|------|---------|----------------|
|
||||||
| API | `https://shortener.aimloperations.com` | `POST/GET /api/links/` |
|
| Prod | `https://piha.lc` | `POST /api/links/` and `GET /<code>` |
|
||||||
| Short | `https://piha.lc` (beta: `https://beta.piha.li`) | phones only — `GET /<code>` |
|
| Beta | `https://beta.piha.li` | same |
|
||||||
|
|
||||||
Local compose: API + redirects on `http://127.0.0.1:8005`.
|
Local compose: API + redirects on `http://127.0.0.1:8005`.
|
||||||
|
|
||||||
@@ -53,7 +52,7 @@ mint without it.
|
|||||||
allows `www.mkdrealtor.com`. `https` only.
|
allows `www.mkdrealtor.com`. `https` only.
|
||||||
|
|
||||||
5. Give the caller **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)
|
- The full token string `name:secret` (they send it as Bearer)
|
||||||
|
|
||||||
Never put the token in git, logs, or the short URL.
|
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
|
```text
|
||||||
# Prod
|
# Prod
|
||||||
SHORTENER_BASE_URL=https://shortener.aimloperations.com
|
SHORTENER_BASE_URL=https://piha.lc
|
||||||
SHORTENER_API_TOKEN=monica:<same-secret>
|
SHORTENER_API_TOKEN=monica:<same-secret>
|
||||||
|
|
||||||
# Beta (when that host exists)
|
# Beta
|
||||||
# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com
|
# SHORTENER_BASE_URL=https://beta.piha.li
|
||||||
# SHORTENER_API_TOKEN=monica:<beta-secret>
|
# SHORTENER_API_TOKEN=monica:<beta-secret>
|
||||||
|
|
||||||
# Local (this service via compose)
|
# 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
|
`short_url` is built from this service’s `PUBLIC_SHORT_URL` (same origin you
|
||||||
hit). It is **not** the API host. Put `short_url` in SMS / email as-is.
|
called). Put `short_url` in SMS / email as-is.
|
||||||
|
|
||||||
### Idempotency `200`
|
### 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
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- Target-host allowlist so a stolen token cannot mint open redirects off the short domain.
|
||||||
- Click counts for campaign reporting.
|
- Click counts for campaign reporting.
|
||||||
@@ -38,7 +38,7 @@ Two surfaces. Mixing them is the failure mode.
|
|||||||
| Surface | Who | Auth | Network |
|
| Surface | Who | Auth | Network |
|
||||||
|---------|-----|------|---------|
|
|---------|-----|------|---------|
|
||||||
| `GET /<code>` | anyone with the SMS | **none** | public short hostname (NPM + TLS) |
|
| `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
|
### 2.1 Public redirect
|
||||||
|
|
||||||
@@ -60,30 +60,24 @@ working.
|
|||||||
`DJANGO_SECRET_KEY` or any `monica_site` webhook secret.
|
`DJANGO_SECRET_KEY` or any `monica_site` webhook secret.
|
||||||
- Do not log the `Authorization` header (Gunicorn/NPM access logs). Strip or disable it.
|
- 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`
|
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).
|
→ **503**. Missing/wrong token → **401**. Do not add CORS `*` (server-to-server only).
|
||||||
|
|
||||||
```
|
```
|
||||||
Internet
|
Internet
|
||||||
│
|
│
|
||||||
├─ NPM: SHORT_DOMAIN (piha.lc / beta.piha.li)
|
└─ NPM: SHORT_DOMAIN (piha.lc / beta.piha.li)
|
||||||
│ location ~*^/[a-z0-9]{4,8}$ → gunicorn (redirects only)
|
GET /<code> → gunicorn (redirects)
|
||||||
│ /api/* → 404 / drop
|
|
||||||
│ /admin/ → 404 / drop
|
|
||||||
│
|
|
||||||
└─ NPM: API hostname (e.g. shortener.aimloperations.com)
|
|
||||||
/api/links/ → gunicorn (Bearer required)
|
/api/links/ → gunicorn (Bearer required)
|
||||||
/admin/ → 404 / drop
|
/admin/ → 404 (Django)
|
||||||
```
|
```
|
||||||
|
|
||||||
- Public NPM for `SHORT_DOMAIN` must not proxy `/api/` or `/admin/`.
|
- One NPM proxy host. `location /` → gunicorn. Django 404s `/admin/` and `/debug/`.
|
||||||
- Public NPM for the API hostname proxies `/api/` only. Do not expose `/admin/`.
|
- `monica_site` calls `SHORTENER_BASE_URL` (`https://piha.lc` or `https://beta.piha.li`).
|
||||||
- `monica_site` calls `SHORTENER_BASE_URL` (the API hostname), never the short
|
- Django admin stays on `SHORT_ADMIN_HOSTS` (localhost). Not on the public host.
|
||||||
hostname, to create links.
|
|
||||||
- Django admin stays on `SHORT_ADMIN_HOSTS` (localhost). Not on the public API host.
|
|
||||||
|
|
||||||
### 2.4 Target allowlist
|
### 2.4 Target allowlist
|
||||||
|
|
||||||
@@ -99,10 +93,11 @@ On create:
|
|||||||
|
|
||||||
Even if NPM is misconfigured, the Django process must refuse the wrong surface:
|
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_PUBLIC_HOSTS` → redirects **and** `/api/` (Bearer).
|
||||||
- `request.get_host()` in `SHORT_API_HOSTS` → `/api/` (still Bearer). May be public DNS.
|
- `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).
|
- `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?
|
Host header?
|
||||||
├─ public short host → GET /<code> 302 + Click row
|
├─ 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)
|
Postgres (own DATABASE_URL)
|
||||||
@@ -191,7 +187,7 @@ url_shortening_service/
|
|||||||
services.py
|
services.py
|
||||||
urls.py
|
urls.py
|
||||||
tests.py
|
tests.py
|
||||||
admin.py # optional; only reachable on API host if enabled
|
admin.py # optional; localhost only
|
||||||
core/
|
core/
|
||||||
views.py # healthz
|
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_ENV` | `dev` / `beta` / `prod` |
|
||||||
| `DJANGO_SECRET_KEY` | Django signing; **not** an API token |
|
| `DJANGO_SECRET_KEY` | Django signing; **not** an API token |
|
||||||
| `DJANGO_DEBUG` | false in prod |
|
| `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 |
|
| `DATABASE_URL` | Postgres; empty → SQLite |
|
||||||
| `SHORT_DOMAIN` | public hostname phones use (`piha.lc` prod, `beta.piha.li` beta) |
|
| `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) |
|
| `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_PUBLIC_HOSTS` | comma list; Host values that serve redirects **and** `/api/` |
|
||||||
| `SHORT_API_HOSTS` | comma list; Host values that serve `/api/` (public API DNS and/or docker name) |
|
| `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) |
|
| `SHORT_ADMIN_HOSTS` | comma list; Host values that serve `/admin/` (default localhost only) |
|
||||||
| `SHORTENER_API_TOKENS` | `name:secret,name:secret` — required for API |
|
| `SHORTENER_API_TOKENS` | `name:secret,name:secret` — required for API |
|
||||||
| `SHORT_ALLOWED_HOSTS` | allowlist for `target_url` hosts |
|
| `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
|
### Caller (`monica_site`) — later, other repo
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SHORTENER_BASE_URL=https://shortener.aimloperations.com
|
SHORTENER_BASE_URL=https://piha.lc
|
||||||
SHORTENER_API_TOKEN=monica:<same-secret>
|
SHORTENER_API_TOKEN=monica:<same-secret>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -403,7 +399,7 @@ Same pattern as `monica_site`:
|
|||||||
- `web`: build `.`, mount `./site`, `DJANGO_ENV=dev` → runserver
|
- `web`: build `.`, mount `./site`, `DJANGO_ENV=dev` → runserver
|
||||||
- **Do not** publish the host port to the LAN unless you are testing redirects.
|
- **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).
|
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`
|
### `docker-compose.prod.yml`
|
||||||
|
|
||||||
@@ -424,12 +420,10 @@ Wait for DB → `migrate --noinput` → if `DJANGO_ENV=dev` then `runserver`, el
|
|||||||
When the domains exist:
|
When the domains exist:
|
||||||
|
|
||||||
1. Public NPM proxy host = `SHORT_DOMAIN` → this container’s `WEB_PORT`.
|
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.
|
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>`.
|
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`):
|
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;
|
- Auth: missing Bearer → 401; wrong token → 401; empty `SHORTENER_API_TOKENS` → 503;
|
||||||
matching named token → 201.
|
matching named token → 201.
|
||||||
- Host split: request to short host `/api/links/` → 404 even with valid Bearer.
|
- Host split: request to short host `/api/links/` without Bearer → 401; valid Bearer → 201.
|
||||||
Public API host without Bearer → 401; valid Bearer → 201. `/admin/` 404 on API host.
|
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;
|
- Allowlist: `https://mkdrealtor.com/x` ok; `http://…` 400; `https://evil.com` 400;
|
||||||
`javascript:alert(1)` 400.
|
`javascript:alert(1)` 400.
|
||||||
- Redirect: active code → 302 to target; inactive/expired/unknown → 404.
|
- Redirect: active code → 302 to target; inactive/expired/unknown → 404.
|
||||||
- Redirect is 302, not 301.
|
- Redirect is 302, not 301.
|
||||||
- Click row created and `click_count` incremented.
|
- Click row created and `click_count` incremented.
|
||||||
- Idempotent create with same `target_url` + `external_ref`.
|
- 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.
|
- 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
|
uv run python manage.py runserver
|
||||||
```
|
```
|
||||||
|
|
||||||
Mint (API host / localhost):
|
Mint (same host / localhost):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sS -X POST http://127.0.0.1:8005/api/links/ \
|
curl -sS -X POST http://127.0.0.1:8005/api/links/ \
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# url_shortening_service
|
# url_shortening_service
|
||||||
|
|
||||||
Django 6 URL shortener. `monica_site` (and other trusted callers) mint links over
|
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
|
Bearer auth on the **same** public host phones use. SMS recipients hit
|
||||||
`GET /<code>` on the **short** domain and get a 302 to the long HTTPS URL.
|
`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`.
|
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 |
|
| Who | Path | Auth | Host |
|
||||||
|-----|------|------|------|
|
|-----|------|------|------|
|
||||||
| Phone / public internet | `GET /<code>` | none | `SHORT_DOMAIN` (NPM + TLS) |
|
| Phone / public internet | `GET /<code>` | none | `SHORT_DOMAIN` (`piha.lc` / `beta.piha.li`) |
|
||||||
| Anyone | `GET /` | none | landing page on the short domain |
|
| Anyone | `GET /` | none | landing page on that same host |
|
||||||
| `monica_site` | `/api/links/` | `Authorization: Bearer name:secret` | own DNS / NPM host — **not** the short domain |
|
| `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**.
|
named Bearer token. No token / wrong token → **401**. No tokens configured → **503**.
|
||||||
`GET /<code>` never requires a token.
|
`GET /<code>` never requires a token.
|
||||||
|
|
||||||
`/api/` and `/admin/` are 404 on the short domain. `/admin/` is also 404 on the
|
`/admin/` is 404 on the public host (localhost only). `GET /debug/` is a mint form
|
||||||
public API hostname (localhost only). `GET /debug/` is a mint form when
|
when `DEBUG=true` and never on the public short host.
|
||||||
`DEBUG=true` and never on the short domain.
|
|
||||||
|
|
||||||
## Local run
|
## Local run
|
||||||
|
|
||||||
@@ -63,11 +63,11 @@ Deploy calls `server-infra/scripts/deploy.sh --app url_shortening_service`. Need
|
|||||||
## Use case
|
## Use case
|
||||||
|
|
||||||
1. Your site POSTs to this service with a Bearer token and a long HTTPS URL.
|
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.
|
3. A person on the public internet opens that short URL.
|
||||||
4. This service 302s them to the long URL and increments `click_count`.
|
4. This service 302s them to the long URL and increments `click_count`.
|
||||||
|
|
||||||
Mint (API host / localhost):
|
Mint (same host / localhost):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sS -X POST http://127.0.0.1:8005/api/links/ \
|
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)
|
## Caller contract (`monica_site`, other repo)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SHORTENER_BASE_URL=https://shortener.aimloperations.com
|
SHORTENER_BASE_URL=https://piha.lc
|
||||||
SHORTENER_API_TOKEN=monica:<same-secret-as-SHORTENER_API_TOKENS>
|
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` +
|
(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.
|
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
|
## Environment
|
||||||
|
|
||||||
@@ -123,9 +123,9 @@ short URL, logs, or git.
|
|||||||
|
|
||||||
## Deploy notes
|
## 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}`.
|
1. `SHORT_DOMAIN` (`piha.lc` prod / `beta.piha.li` beta) — `/` landing,
|
||||||
Drop `/api/`, `/admin/`, `/debug/`.
|
`GET /[a-z0-9]{4,8}`, and `/api/` (Bearer). Django 404s `/admin/` and `/debug/`
|
||||||
2. API hostname — proxy `/api/` only. Drop `/admin/`. Add that Host to
|
on this host. Add it to `DJANGO_ALLOWED_HOSTS`, `SHORT_PUBLIC_HOSTS`, and
|
||||||
`DJANGO_ALLOWED_HOSTS` and `SHORT_API_HOSTS`.
|
`SHORT_API_HOSTS`.
|
||||||
|
|||||||
+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
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import Http404, HttpRequest
|
from django.http import Http404, HttpRequest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_host(host: str) -> str:
|
def _normalize_host(host: str) -> str:
|
||||||
return host.split(":")[0].lower().rstrip(".")
|
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 []))
|
return _host_in(host, list(getattr(settings, "SHORT_ADMIN_HOSTS", []) or []))
|
||||||
|
|
||||||
|
|
||||||
class HostSplitMiddleware:
|
def serves_api(host: str) -> bool:
|
||||||
"""Short host = redirects only. API host = /api/ (Bearer). Admin = local only.
|
"""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):
|
def __init__(self, get_response):
|
||||||
@@ -61,8 +70,13 @@ class HostSplitMiddleware:
|
|||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|
||||||
if path.startswith("/api/"):
|
if path.startswith("/api/"):
|
||||||
# Short redirect hostname never serves the API, even if mis-listed.
|
if not serves_api(host):
|
||||||
if is_public_host(host) or not is_api_host(host):
|
logger.warning(
|
||||||
|
"blocked /api/ host=%s public=%s api=%s",
|
||||||
|
host,
|
||||||
|
is_public_host(host),
|
||||||
|
is_api_host(host),
|
||||||
|
)
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|||||||
+23
-30
@@ -21,7 +21,7 @@ SETTINGS = dict(
|
|||||||
SHORT_DOMAIN="piha.lc",
|
SHORT_DOMAIN="piha.lc",
|
||||||
PUBLIC_SHORT_URL="https://piha.lc",
|
PUBLIC_SHORT_URL="https://piha.lc",
|
||||||
SHORT_PUBLIC_HOSTS=["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_ADMIN_HOSTS=["localhost", "127.0.0.1"],
|
||||||
SHORT_ALLOWED_HOSTS=["mkdrealtor.com"],
|
SHORT_ALLOWED_HOSTS=["mkdrealtor.com"],
|
||||||
CLICK_IP_PEPPER="test-pepper-not-the-secret-key",
|
CLICK_IP_PEPPER="test-pepper-not-the-secret-key",
|
||||||
@@ -88,7 +88,7 @@ class AuthTests(TestCase):
|
|||||||
|
|
||||||
@override_settings(**SETTINGS)
|
@override_settings(**SETTINGS)
|
||||||
class HostSplitTests(TestCase):
|
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(
|
response = self.client.post(
|
||||||
"/api/links/",
|
"/api/links/",
|
||||||
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
|
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
|
||||||
@@ -96,63 +96,56 @@ class HostSplitTests(TestCase):
|
|||||||
HTTP_AUTHORIZATION=AUTH,
|
HTTP_AUTHORIZATION=AUTH,
|
||||||
HTTP_HOST="piha.lc",
|
HTTP_HOST="piha.lc",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
@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)
|
|
||||||
|
|
||||||
def test_public_host_admin_404(self):
|
def test_public_host_admin_404(self):
|
||||||
response = self.client.get("/admin/", HTTP_HOST="piha.lc")
|
response = self.client.get("/admin/", HTTP_HOST="piha.lc")
|
||||||
self.assertEqual(response.status_code, 404)
|
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(
|
response = self.client.post(
|
||||||
"/api/links/",
|
"/api/links/",
|
||||||
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
|
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
HTTP_HOST="shortener.example.com",
|
HTTP_HOST="piha.lc",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
self.assertEqual(response["WWW-Authenticate"], "Bearer")
|
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(
|
response = self.client.post(
|
||||||
"/api/links/",
|
"/api/links/",
|
||||||
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
|
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
HTTP_AUTHORIZATION="Bearer monica:wrong-secret",
|
HTTP_AUTHORIZATION="Bearer monica:wrong-secret",
|
||||||
HTTP_HOST="shortener.example.com",
|
HTTP_HOST="piha.lc",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 401)
|
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(
|
response = self.client.post(
|
||||||
"/api/links/",
|
"/api/links/",
|
||||||
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
|
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
HTTP_AUTHORIZATION=AUTH,
|
HTTP_AUTHORIZATION=AUTH,
|
||||||
HTTP_HOST="shortener.example.com",
|
HTTP_HOST="piha.lc",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 201)
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
def test_public_api_host_admin_404(self):
|
@override_settings(
|
||||||
response = self.client.get("/admin/", HTTP_HOST="shortener.example.com")
|
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)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
def test_healthz_on_public_and_api(self):
|
def test_healthz_on_public_and_api(self):
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ SHORT_API_HOSTS = env_list(
|
|||||||
"SHORT_API_HOSTS",
|
"SHORT_API_HOSTS",
|
||||||
"localhost,127.0.0.1,0.0.0.0,testserver,web,url-shortener",
|
"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")
|
SHORT_ADMIN_HOSTS = env_list("SHORT_ADMIN_HOSTS", "localhost,127.0.0.1")
|
||||||
SHORTENER_API_TOKENS = parse_api_tokens(env("SHORTENER_API_TOKENS", "") or "")
|
SHORTENER_API_TOKENS = parse_api_tokens(env("SHORTENER_API_TOKENS", "") or "")
|
||||||
SHORT_ALLOWED_HOSTS = env_list(
|
SHORT_ALLOWED_HOSTS = env_list(
|
||||||
|
|||||||
Reference in New Issue
Block a user