Closes #11. ## Summary - Serve Django admin on LAN IP `10.0.0.128` (not `piha.li`). Compose now publishes `0.0.0.0:8005` and passes `SHORT_ADMIN_HOSTS` into the container. - Admin index has a campaign mint form: domain, campaign, source, metric. Save builds `https://{domain}/?utm_campaign=&utm_source=&utm_medium=` (metric) and shows a copyable short URL. - Public `/admin/` on `piha.lc` / `piha.li` stays 404. ## Test plan - [ ] `cd site && uv run python manage.py test` - [ ] Recreate compose (`docker compose up --build`) so `WEB_BIND` / `SHORT_ADMIN_HOSTS` take effect - [ ] From another LAN machine: `http://10.0.0.128:8005/admin/` (staff login) shows the mint form - [ ] Save a link for an allowlisted domain, copy the short URL, confirm it 302s - [ ] `https://piha.li/admin` still 404 Reviewed-on: #12
499 lines
16 KiB
Markdown
499 lines
16 KiB
Markdown
# URL Shortening Service — Implementation
|
||
|
||
Django 6 service in Docker, packaged with `uv`. First caller is `monica_site` (SMS campaign
|
||
bodies and ad-hoc texts). A short public domain will be pointed at this service later;
|
||
until then a subdomain is enough. Public short hosts: `piha.lc` (prod), `beta.piha.li` (beta).
|
||
|
||
This document is the build spec. Implement in this repo. Do not fold the shortener into
|
||
`monica_site`.
|
||
|
||
---
|
||
|
||
## 1. Goals & non-goals
|
||
|
||
### Goals
|
||
|
||
- 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 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.
|
||
- Same deploy shape as `monica_site`: `uv`, Docker, Postgres in prod, Gitea CI, `server-infra`.
|
||
|
||
### Non-goals (v1)
|
||
|
||
- Public web UI or Django admin exposed on the short domain.
|
||
- Query-string tokens (`?token=`). Header only.
|
||
- Per-recipient unique codes (who clicked). Add later if needed.
|
||
- Custom vanity slugs (`/openhouse`). Random codes only in v1.
|
||
- Multi-tenant SaaS, rate-limit dashboards, QR generation.
|
||
|
||
---
|
||
|
||
## 2. Security model (do this first)
|
||
|
||
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** | same public hostname. Token is the lock. |
|
||
|
||
### 2.1 Public redirect
|
||
|
||
SMS recipients have no token. Do **not** put Bearer on `GET /<code>`. That path is the
|
||
product.
|
||
|
||
Use **302**, not 301. 301 is cached forever and later click counts (and disable) stop
|
||
working.
|
||
|
||
### 2.2 Authenticated API
|
||
|
||
- `Authorization: Bearer <named-token>` on every `/api/` request.
|
||
- If `SHORTENER_API_TOKENS` is empty, API returns **503**. Fail closed.
|
||
- Constant-time compare (`hmac.compare_digest`) against every configured token.
|
||
- Named tokens: `monica:<secret>`, `admin:<secret>`. Store the name on the `ShortLink`
|
||
row as `created_by_token`.
|
||
- Generate secrets with `python -c "import secrets; print(secrets.token_urlsafe(32))"`.
|
||
- Never put the token in the short URL, logs, or git. Do not reuse
|
||
`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 One public hostname (Bearer is the lock on /api/)
|
||
|
||
`/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)
|
||
GET /<code> → gunicorn (redirects)
|
||
/api/links/ → gunicorn (Bearer required)
|
||
/admin/ → 404 (Django)
|
||
```
|
||
|
||
- 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 / LAN IP). Not on the public host.
|
||
|
||
### 2.4 Target allowlist
|
||
|
||
A valid Bearer still must not mint arbitrary redirects.
|
||
|
||
On create:
|
||
|
||
- `https` only (reject `http`, `javascript:`, `data:`, protocol-relative `//evil.com`).
|
||
- Hostname must match `SHORT_ALLOWED_HOSTS` (exact or suffix, e.g. `mkdrealtor.com`).
|
||
- Normalize / reject credentials in the URL (`https://user:pass@host`).
|
||
|
||
### 2.5 Host routing inside the app
|
||
|
||
Even if NPM is misconfigured, the Django process must refuse the wrong surface:
|
||
|
||
- `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 and `10.0.0.128` by default).
|
||
- `/healthz/` allowed on both. No secrets in the body.
|
||
|
||
---
|
||
|
||
## 3. Architecture
|
||
|
||
```
|
||
Phone SMS: https://mkd.to/a3k9
|
||
│
|
||
▼
|
||
Nginx Proxy Manager (TLS)
|
||
│
|
||
▼
|
||
url_shortening_service (this repo)
|
||
Docker: gunicorn + uv
|
||
│
|
||
Host header?
|
||
├─ public short host → GET /<code> 302 + Click row
|
||
│ → /api/links/ (Bearer)
|
||
└─ localhost / docker → /api/links/ (Bearer)
|
||
│
|
||
▼
|
||
Postgres (own DATABASE_URL)
|
||
```
|
||
|
||
Caller (`monica_site`) is an HTTP client. It does not share this database.
|
||
|
||
Until the purchased short domain exists:
|
||
|
||
- Set `SHORT_DOMAIN=piha.lc` (or `localhost:8005` locally).
|
||
- Codes do not change when DNS is swapped. Only `SHORT_DOMAIN` / `PUBLIC_SHORT_URL` change.
|
||
|
||
---
|
||
|
||
## 4. Stack
|
||
|
||
Mirror `monica_site` so deploy muscle memory is the same.
|
||
|
||
| Piece | Choice |
|
||
|-------|--------|
|
||
| Python | 3.12 |
|
||
| Package manager | `uv` (`pyproject.toml` + committed `uv.lock`) |
|
||
| Framework | Django 6 |
|
||
| WSGI | gunicorn in prod/beta; `runserver` when `DJANGO_ENV=dev` |
|
||
| Static | WhiteNoise (almost unused; keep for admin if enabled internally) |
|
||
| DB | Postgres via `DATABASE_URL`; SQLite when unset (tests / local) |
|
||
| Image | `python:3.12-slim` + copy `uv` from `ghcr.io/astral-sh/uv` |
|
||
| Queue | none in v1 (no worker) |
|
||
| Extra deps | none for codes (`secrets`); `psycopg[binary]`, `gunicorn`, `whitenoise` |
|
||
|
||
No Redis, Celery, or frontend build.
|
||
|
||
---
|
||
|
||
## 5. Project layout
|
||
|
||
```
|
||
url_shortening_service/
|
||
Implementation.md ← this file
|
||
README.md
|
||
pyproject.toml
|
||
uv.lock
|
||
Dockerfile
|
||
docker-compose.yml # web + postgres, hot reload
|
||
docker-compose.prod.yml # no bundled postgres; DATABASE_URL from env
|
||
.env.example
|
||
.env.prod.example
|
||
.gitignore
|
||
.gitea/workflows/ci.yml
|
||
scripts/docker-entrypoint.sh
|
||
site/
|
||
manage.py
|
||
shortener/ # Django project package
|
||
settings/
|
||
__init__.py
|
||
base.py
|
||
dev.py
|
||
beta.py
|
||
prod.py
|
||
urls.py
|
||
wsgi.py
|
||
asgi.py
|
||
links/ # the app
|
||
models.py
|
||
views.py
|
||
api.py
|
||
auth.py
|
||
services.py
|
||
urls.py
|
||
tests.py
|
||
admin.py # optional; localhost only
|
||
core/
|
||
views.py # healthz
|
||
```
|
||
|
||
Settings split by `DJANGO_ENV` like `monica_site`.
|
||
|
||
---
|
||
|
||
## 6. Data model
|
||
|
||
### `ShortLink`
|
||
|
||
| Field | Type | Notes |
|
||
|-------|------|-------|
|
||
| `id` | UUID PK | |
|
||
| `code` | `CharField(8)`, unique, indexed | 4–6 chars from alphabet below |
|
||
| `target_url` | `URLField(2048)` | stored canonical https URL |
|
||
| `title` | `CharField(200)`, blank | caller-supplied note |
|
||
| `created_by_token` | `CharField(64)` | token **name** (`monica`), never the secret |
|
||
| `external_ref` | `CharField(64)`, blank, indexed | optional caller id (campaign UUID) |
|
||
| `is_active` | bool, default True | |
|
||
| `expires_at` | datetime, nullable | |
|
||
| `click_count` | positive int, default 0 | denormalized |
|
||
| `created_at` / `updated_at` | timestamps | |
|
||
|
||
Disable without deleting. Inactive or expired codes 404 (no redirect). Do not retarget an
|
||
existing code to a new URL in v1 (prevents swapping a live SMS onto a new destination).
|
||
|
||
### `Click`
|
||
|
||
| Field | Type | Notes |
|
||
|-------|------|-------|
|
||
| `id` | UUID PK | |
|
||
| `link` | FK `ShortLink` | |
|
||
| `occurred_at` | datetime | |
|
||
| `ip_hash` | `CharField(64)`, blank | HMAC-SHA256 of remote IP with a server pepper; never store raw IP |
|
||
| `user_agent` | `CharField(512)`, blank | truncated |
|
||
| `referrer` | `CharField(1024)`, blank | |
|
||
|
||
Increment `ShortLink.click_count` in the same request as inserting `Click` (or
|
||
`F('click_count') + 1`). Do not block the 302 on analytics failure: log and still redirect
|
||
if the click insert fails.
|
||
|
||
### Code alphabet
|
||
|
||
```
|
||
23456789abcdefghjkmnpqrstuvwxyz
|
||
```
|
||
|
||
No `0/O`, `1/l/I`. Length 6 by default (`SHORT_CODE_LENGTH=6`). Collision: retry a small
|
||
number of times, then 500.
|
||
|
||
Do not accept caller-supplied codes in v1.
|
||
|
||
---
|
||
|
||
## 7. HTTP API (internal host only)
|
||
|
||
All `/api/` routes require Bearer. JSON in/out. CSRF exempt (token auth, no cookies).
|
||
|
||
Prefix: `/api/links/`
|
||
|
||
### `POST /api/links/`
|
||
|
||
Create.
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"target_url": "https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms",
|
||
"title": "Oak St listing",
|
||
"external_ref": "campaign-uuid-optional",
|
||
"expires_at": null
|
||
}
|
||
```
|
||
|
||
Response `201`:
|
||
|
||
```json
|
||
{
|
||
"code": "a3k9xm",
|
||
"short_url": "https://piha.lc/a3k9xm",
|
||
"target_url": "https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms",
|
||
"title": "Oak St listing",
|
||
"is_active": true,
|
||
"click_count": 0,
|
||
"created_at": "2026-08-30T10:00:00Z"
|
||
}
|
||
```
|
||
|
||
`short_url` is built from `PUBLIC_SHORT_URL` (the public origin phones will hit), **not**
|
||
from the internal `Host` header.
|
||
|
||
Errors:
|
||
|
||
- `400` invalid URL / host not allowlisted
|
||
- `401` missing/wrong Bearer
|
||
- `503` no tokens configured
|
||
|
||
Idempotency (v1, keep simple): if the same `target_url` + `external_ref` + still-active
|
||
link exists, return that row `200` instead of minting a duplicate. If `external_ref` is
|
||
empty, always mint a new code.
|
||
|
||
### `GET /api/links/`
|
||
|
||
List. Query: `?external_ref=`, `?is_active=true`, pagination (`limit`/`offset`, cap 100).
|
||
|
||
### `GET /api/links/<code>/`
|
||
|
||
Detail including `click_count`.
|
||
|
||
### `POST /api/links/<code>/disable/`
|
||
|
||
Set `is_active=false`. Idempotent. `200`.
|
||
|
||
No DELETE in v1. No PATCH of `target_url`.
|
||
|
||
### Auth helper
|
||
|
||
```python
|
||
# links/auth.py
|
||
# Parse Authorization: Bearer <token>
|
||
# Split named tokens from SHORTENER_API_TOKENS (comma-separated name:secret)
|
||
# compare_digest each secret
|
||
# Return token name or None
|
||
```
|
||
|
||
Decorator / mixin on all API views. Wrong or missing → `401` with
|
||
`WWW-Authenticate: Bearer`. Do not distinguish "unknown token" vs "malformed" in the body.
|
||
|
||
---
|
||
|
||
## 8. Public redirect
|
||
|
||
`GET /<code>` on the public host.
|
||
|
||
1. Lookup code (404 if missing, inactive, or expired).
|
||
2. Insert `Click`, bump `click_count` (best-effort).
|
||
3. `HttpResponseRedirect(target_url)` — **302**.
|
||
|
||
Reject codes that do not match `^[a-z0-9]{4,8}$` with 404 (no extra work).
|
||
|
||
`GET /` on the public host: landing page (“URL shortening service”, credit
|
||
`aimloperations.com`). Does not advertise the API.
|
||
|
||
`GET /debug/`: mint form, **DEBUG only**, never on `SHORT_PUBLIC_HOSTS`.
|
||
|
||
`GET /healthz/`: `{"status": "ok"}`.
|
||
|
||
---
|
||
|
||
## 9. Environment
|
||
|
||
### This service
|
||
|
||
| Var | Purpose |
|
||
|-----|---------|
|
||
| `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 (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 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 + `10.0.0.128`) |
|
||
| `SHORTENER_API_TOKENS` | `name:secret,name:secret` — required for API |
|
||
| `SHORT_ALLOWED_HOSTS` | allowlist for `target_url` hosts |
|
||
| `SHORT_CODE_LENGTH` | default `6` |
|
||
| `CLICK_IP_PEPPER` | HMAC key for `ip_hash`; distinct from `DJANGO_SECRET_KEY` |
|
||
| `GUNICORN_WORKERS` | default `2` |
|
||
|
||
`.env.example` documents all of these. Prod secrets live in
|
||
`~/Documents/secrets/url_shortening_service/` on the control node, same convention as
|
||
`monica_site`.
|
||
|
||
### Caller (`monica_site`) — later, other repo
|
||
|
||
```text
|
||
SHORTENER_BASE_URL=https://piha.lc
|
||
SHORTENER_API_TOKEN=monica:<same-secret>
|
||
```
|
||
|
||
`monica_site` sends `Authorization: Bearer monica:<secret>` to
|
||
`POST {SHORTENER_BASE_URL}/api/links/` and substitutes `short_url` into SMS bodies
|
||
**after** UTM is attached.
|
||
|
||
Do not implement the caller in this repo. Document the contract only.
|
||
|
||
---
|
||
|
||
## 10. Docker
|
||
|
||
### `Dockerfile`
|
||
|
||
Same pattern as `monica_site`:
|
||
|
||
- `FROM python:3.12-slim`
|
||
- install `libpq5`, copy `uv`
|
||
- `uv sync --frozen --no-dev`
|
||
- copy `site/` + entrypoint
|
||
- `EXPOSE 8000`
|
||
|
||
### `docker-compose.yml` (dev)
|
||
|
||
- `db`: Postgres 16
|
||
- `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 hostname: service name `web` (or `url-shortener`).
|
||
|
||
### `docker-compose.prod.yml`
|
||
|
||
- `web` only. External Postgres via `DATABASE_URL`.
|
||
- `ports: "${WEB_PORT:-8005}:8000"` (pick a free host port; document it).
|
||
- `env_file: .env`
|
||
- No worker profile.
|
||
|
||
### Entrypoint
|
||
|
||
Wait for DB → `migrate --noinput` → if `DJANGO_ENV=dev` then `runserver`, else
|
||
`collectstatic` + gunicorn.
|
||
|
||
---
|
||
|
||
## 11. NPM / deploy notes (ops, not code)
|
||
|
||
When the domains exist:
|
||
|
||
1. Public NPM proxy host = `SHORT_DOMAIN` → this container’s `WEB_PORT`.
|
||
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`, `SHORT_PUBLIC_HOSTS`, and `SHORT_API_HOSTS`.
|
||
5. `PUBLIC_SHORT_URL=https://<that-domain>`.
|
||
|
||
CI (same split as `monica_site`, default branch `master`):
|
||
|
||
- `.gitea/workflows/ci.yml` — unit tests on pull requests
|
||
- `.gitea/workflows/deploy-beta.yml` — on push to `master`: unit tests → compose
|
||
tests → `server-infra/scripts/deploy.sh --app url_shortening_service --env beta`
|
||
- `.gitea/workflows/deploy-prod.yml` — `workflow_dispatch` (Actions button): same
|
||
tests, then `--env prod`
|
||
|
||
---
|
||
|
||
## 12. Tests (required before merge)
|
||
|
||
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/` 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`.
|
||
- Code charset: generated codes only use the unambiguous alphabet.
|
||
|
||
---
|
||
|
||
## 13. Implementation order
|
||
|
||
1. `uv init` / `pyproject.toml` + Django project + settings/env helpers.
|
||
2. `links` models + migration.
|
||
3. `links/auth.py` + API views (create/list/detail/disable).
|
||
4. Redirect view + host-split middleware.
|
||
5. Dockerfile, compose, entrypoint, `.env.example`.
|
||
6. Tests in §12.
|
||
7. README: how to run locally, how a caller authenticates, what is public vs internal.
|
||
8. Stop. Caller integration lives in `monica_site` after this service is up.
|
||
|
||
Phase 2 (not this pass): SMS auto-shorten in `monica_site` composer, custom slugs,
|
||
per-recipient codes, internal-only admin UI.
|
||
|
||
---
|
||
|
||
## 14. Local run (target)
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
# set SHORTENER_API_TOKENS=monica:dev-only-token
|
||
docker compose up --build
|
||
# or:
|
||
uv sync
|
||
cd site && uv run python manage.py migrate
|
||
uv run python manage.py runserver
|
||
```
|
||
|
||
Mint (same host / localhost):
|
||
|
||
```bash
|
||
curl -sS -X POST http://127.0.0.1:8005/api/links/ \
|
||
-H "Authorization: Bearer monica:dev-only-token" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"target_url":"https://mkdrealtor.com/","title":"test"}'
|
||
```
|
||
|
||
Follow (public path):
|
||
|
||
```bash
|
||
curl -sSI http://127.0.0.1:8005/<code>
|
||
```
|