# 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 /` 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). - 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 /` | 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. | ### 2.1 Public redirect SMS recipients have no token. Do **not** put Bearer on `GET /`. 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 ` 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:`, `admin:`. 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 Two public hostnames (Bearer is the lock on /api/) `/api/links/` may have its own public DNS record. 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 ``` - 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. ### 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` → redirect view only. `/api/` → 404. - `request.get_host()` in `SHORT_API_HOSTS` → `/api/` (still Bearer). May be public DNS. - `request.get_host()` in `SHORT_ADMIN_HOSTS` → `/admin/` (localhost only by default). - `/healthz/` allowed on both public hosts. 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 / 302 + Click row └─ API host (own DNS) → /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; only reachable on API host if enabled 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//` Detail including `click_count`. ### `POST /api/links//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 # 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 /` 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 **and** API hostname | | `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_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 | | `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://shortener.aimloperations.com SHORTENER_API_TOKEN=monica: ``` `monica_site` sends `Authorization: Bearer monica:` 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 API 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) and the code regex. `/api/`, `/admin/`, `/debug/` → 404. 3. TLS like other apps. 4. Add `SHORT_DOMAIN` to `DJANGO_ALLOWED_HOSTS` and `SHORT_PUBLIC_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`): - `.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/` → 404 even with valid Bearer. Public API host without Bearer → 401; valid Bearer → 201. `/admin/` 404 on API 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. - 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 (API 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/ ```