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
16 KiB
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_sitecan 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_TOKENSis 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 theShortLinkrow ascreated_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_KEYor anymonica_sitewebhook secret. - Do not log the
Authorizationheader (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_sitecallsSHORTENER_BASE_URL(https://piha.lcorhttps://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:
httpsonly (rejecthttp,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()inSHORT_PUBLIC_HOSTS→ redirects and/api/(Bearer).request.get_host()inSHORT_API_HOSTS→/api/(still Bearer). Extra names (localhost, docker) only.request.get_host()inSHORT_ADMIN_HOSTS→/admin/(localhost and10.0.0.128by 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(orlocalhost:8005locally). - Codes do not change when DNS is swapped. Only
SHORT_DOMAIN/PUBLIC_SHORT_URLchange.
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:
{
"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:
{
"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:
400invalid URL / host not allowlisted401missing/wrong Bearer503no 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
# 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.
- Lookup code (404 if missing, inactive, or expired).
- Insert
Click, bumpclick_count(best-effort). 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
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, copyuv uv sync --frozen --no-dev- copy
site/+ entrypoint EXPOSE 8000
docker-compose.yml (dev)
db: Postgres 16web: 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(orurl-shortener).
docker-compose.prod.yml
webonly. External Postgres viaDATABASE_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:
- Public NPM proxy host =
SHORT_DOMAIN→ this container’sWEB_PORT. - Proxy
/(landing), the code regex, and/api/. Django 404s/admin/and/debug/. - TLS like other apps.
- Add
SHORT_DOMAINtoDJANGO_ALLOWED_HOSTS,SHORT_PUBLIC_HOSTS, andSHORT_API_HOSTS. 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 tomaster: 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/xok;http://…400;https://evil.com400;javascript:alert(1)400. - Redirect: active code → 302 to target; inactive/expired/unknown → 404.
- Redirect is 302, not 301.
- Click row created and
click_countincremented. - Idempotent create with same
target_url+external_ref. short_urlusesPUBLIC_SHORT_URL.- Code charset: generated codes only use the unambiguous alphabet.
13. Implementation order
uv init/pyproject.toml+ Django project + settings/env helpers.linksmodels + migration.links/auth.py+ API views (create/list/detail/disable).- Redirect view + host-split middleware.
- Dockerfile, compose, entrypoint,
.env.example. - Tests in §12.
- README: how to run locally, how a caller authenticates, what is public vs internal.
- Stop. Caller integration lives in
monica_siteafter 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)
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):
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):
curl -sSI http://127.0.0.1:8005/<code>