Implement v1 URL shortener (Bearer API, public 302, landing, CI) #2

Merged
westfarn merged 1 commits from feat/1-v1-url-shortener into master 2026-08-30 04:55:33 -07:00
48 changed files with 3469 additions and 2 deletions
+36
View File
@@ -0,0 +1,36 @@
# Local development defaults. Copy to `.env` (gitignored) — never commit secrets.
# docker compose auto-loads `.env` for ${VAR} substitution into the web container.
DJANGO_ENV=dev
DJANGO_DEBUG=true
DJANGO_SECRET_KEY=dev-only-change-me
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,web,url-shortener
# Leave empty for SQLite when running manage.py on the host.
# Compose ignores this and uses the bundled Postgres via COMPOSE_DATABASE_URL.
# DATABASE_URL=
SITE_NAME=URL Shortening Service
CREDIT_NAME=AI ML Operations
CREDIT_URL=https://aimloperations.com
SHORT_DOMAIN=localhost:8005
# Origin printed in minted short_url (phones hit this). Local: this machine.
PUBLIC_SHORT_URL=http://127.0.0.1:8005
# Host values that only serve GET /<code> (no /api/).
SHORT_PUBLIC_HOSTS=go.mkdrealtor.com
# Host values that serve /api/ (Bearer required). May include a public DNS name.
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.
SHORT_ADMIN_HOSTS=localhost,127.0.0.1
# Named, rotatable tokens. This is what keeps /api/ closed on a public hostname.
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
# Format: name:secret,name:secret — never reuse DJANGO_SECRET_KEY.
SHORTENER_API_TOKENS=monica:dev-only-token
# target_url hostname allowlist (exact or suffix).
SHORT_ALLOWED_HOSTS=mkdrealtor.com,aimloperations.com,*.aimloperations.com
SHORT_CODE_LENGTH=6
# HMAC pepper for click IP hashes. Distinct from DJANGO_SECRET_KEY.
CLICK_IP_PEPPER=dev-click-pepper-change-me
GUNICORN_WORKERS=2
+54
View File
@@ -0,0 +1,54 @@
# Secret env files for server-infra deploy.
# Copy to the control node (never commit):
# ~/Documents/secrets/url_shortening_service/url_shortening_service_prod.env
# ~/Documents/secrets/url_shortening_service/url_shortening_service_beta.env
#
# Docker Compose: if a secret contains $ (e.g. in DATABASE_URL password), escape each
# $ as $$ or compose will treat $word as a variable.
# =============================================================================
# PROD
# =============================================================================
DJANGO_ENV=prod
DJANGO_DEBUG=false
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
# Public short host AND public API hostname (plus docker names if used).
DJANGO_ALLOWED_HOSTS=go.mkdrealtor.com,shortener.aimloperations.com,url-shortener,web
# Shared external Postgres
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/url_shortener
# Host port (must match server-infra). NPM proxies SHORT_DOMAIN here.
WEB_PORT=8005
SHORT_DOMAIN=go.mkdrealtor.com
PUBLIC_SHORT_URL=https://go.mkdrealtor.com
SHORT_PUBLIC_HOSTS=go.mkdrealtor.com
SHORT_API_HOSTS=shortener.aimloperations.com,url-shortener,web
SHORT_ADMIN_HOSTS=localhost,127.0.0.1
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
# This token is the only thing that authorizes minting. Treat it as a secret.
# monica_site sends: Authorization: Bearer monica:<same-secret>
SHORTENER_API_TOKENS=monica:replace-with-token-urlsafe-32
SHORT_ALLOWED_HOSTS=mkdrealtor.com
SHORT_CODE_LENGTH=6
CLICK_IP_PEPPER=replace-with-a-distinct-pepper
GUNICORN_WORKERS=2
GUNICORN_BIND=0.0.0.0:8000
# =============================================================================
# BETA overrides
# File: url_shortening_service_beta.env
# =============================================================================
# DJANGO_ENV=beta
# DJANGO_DEBUG=false
# DJANGO_SECRET_KEY=replace-with-a-different-beta-secret
# DJANGO_ALLOWED_HOSTS=go-beta.example.com,shortener-beta.aimloperations.com,url-shortener,web
# DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/url_shortener_beta
# WEB_PORT=8015
# SHORT_DOMAIN=go-beta.example.com
# PUBLIC_SHORT_URL=https://go-beta.example.com
# SHORT_PUBLIC_HOSTS=go-beta.example.com
# SHORTENER_API_TOKENS=monica:replace-with-a-different-token
# CLICK_IP_PEPPER=replace-with-a-different-pepper
+36
View File
@@ -0,0 +1,36 @@
name: CI
on:
pull_request:
branches: [master]
jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
DATABASE_URL: ""
DB_HOST: ""
SHORTENER_API_TOKENS: monica:dev-only-token
PUBLIC_SHORT_URL: https://go.mkdrealtor.com
SHORT_PUBLIC_HOSTS: go.mkdrealtor.com
SHORT_API_HOSTS: testserver,localhost,127.0.0.1
SHORT_ALLOWED_HOSTS: mkdrealtor.com
CLICK_IP_PEPPER: test-pepper
run: |
cd site
uv run python manage.py test
+85
View File
@@ -0,0 +1,85 @@
name: Deploy Beta
on:
push:
branches:
- master
jobs:
unit-tests:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
DATABASE_URL: ""
DB_HOST: ""
SHORTENER_API_TOKENS: monica:dev-only-token
PUBLIC_SHORT_URL: https://go.mkdrealtor.com
SHORT_PUBLIC_HOSTS: go.mkdrealtor.com
SHORT_API_HOSTS: testserver,localhost,127.0.0.1
SHORT_ALLOWED_HOSTS: mkdrealtor.com
CLICK_IP_PEPPER: test-pepper
run: |
cd site
uv run python manage.py test
docker:
needs: unit-tests
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker compose build
- name: Run containerized tests
run: |
set -euo pipefail
unset DATABASE_URL DB_HOST DB_NAME DB_USER DB_PASSWORD DB_PORT \
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
DJANGO_ALLOWED_HOSTS || true
PROJECT="shortener-ci-${{ gitea.sha }}"
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
trap cleanup EXIT
docker compose -p "$PROJECT" up -d --wait db
docker compose -p "$PROJECT" run --rm --no-deps --entrypoint "" \
-e DJANGO_ENV=dev \
-e DJANGO_SECRET_KEY=test-secret-key \
-e DJANGO_DEBUG=true \
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
-e DATABASE_URL=postgres://url_shortener:url_shortener@db:5432/url_shortener \
-e SHORTENER_API_TOKENS=monica:dev-only-token \
-e PUBLIC_SHORT_URL=https://go.mkdrealtor.com \
-e SHORT_PUBLIC_HOSTS=go.mkdrealtor.com \
-e SHORT_API_HOSTS=testserver,localhost,127.0.0.1 \
-e SHORT_ALLOWED_HOSTS=mkdrealtor.com \
-e CLICK_IP_PEPPER=test-pepper \
web uv run python manage.py test
deploy-beta:
needs: docker
runs-on: self-hosted
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
steps:
- name: Deploy url_shortening_service beta to all webservers
run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
--app url_shortening_service \
--env beta \
--ref "${{ gitea.sha }}"
+83
View File
@@ -0,0 +1,83 @@
name: Deploy Prod
on:
workflow_dispatch: {}
jobs:
unit-tests:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
DATABASE_URL: ""
DB_HOST: ""
SHORTENER_API_TOKENS: monica:dev-only-token
PUBLIC_SHORT_URL: https://go.mkdrealtor.com
SHORT_PUBLIC_HOSTS: go.mkdrealtor.com
SHORT_API_HOSTS: testserver,localhost,127.0.0.1
SHORT_ALLOWED_HOSTS: mkdrealtor.com
CLICK_IP_PEPPER: test-pepper
run: |
cd site
uv run python manage.py test
docker:
needs: unit-tests
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker compose build
- name: Run containerized tests
run: |
set -euo pipefail
unset DATABASE_URL DB_HOST DB_NAME DB_USER DB_PASSWORD DB_PORT \
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
DJANGO_ALLOWED_HOSTS || true
PROJECT="shortener-ci-${{ gitea.sha }}"
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
trap cleanup EXIT
docker compose -p "$PROJECT" up -d --wait db
docker compose -p "$PROJECT" run --rm --no-deps --entrypoint "" \
-e DJANGO_ENV=dev \
-e DJANGO_SECRET_KEY=test-secret-key \
-e DJANGO_DEBUG=true \
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
-e DATABASE_URL=postgres://url_shortener:url_shortener@db:5432/url_shortener \
-e SHORTENER_API_TOKENS=monica:dev-only-token \
-e PUBLIC_SHORT_URL=https://go.mkdrealtor.com \
-e SHORT_PUBLIC_HOSTS=go.mkdrealtor.com \
-e SHORT_API_HOSTS=testserver,localhost,127.0.0.1 \
-e SHORT_ALLOWED_HOSTS=mkdrealtor.com \
-e CLICK_IP_PEPPER=test-pepper \
web uv run python manage.py test
deploy-prod:
needs: docker
runs-on: self-hosted
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
steps:
- name: Deploy url_shortening_service prod to all webservers
run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
--app url_shortening_service \
--env prod \
--ref "${{ gitea.sha }}"
+2 -2
View File
@@ -144,8 +144,8 @@ venv.bak/
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mkdocs documentation (do not ignore ./site — that is the Django project)
/mkdocs_site
# mypy
.mypy_cache/
+289
View File
@@ -0,0 +1,289 @@
# URL shortener — caller API
For other services (`monica_site`, later callers) that mint short links.
This service has two public hostnames. **Call the API host.** Never create links
on the short domain (`aiml.pw` / `cidinn.li` / `go.mkdrealtor.com`). That host
only serves `GET /` (landing) and `GET /<code>` (302). `/api/` there is **404**.
| Host | Example | What you call |
|------|---------|----------------|
| API | `https://shortener.aimloperations.com` | `POST/GET /api/links/` |
| Short | `https://aiml.pw` (or `cidinn.li`) | phones only — `GET /<code>` |
Local compose: API + redirects on `http://127.0.0.1:8005`.
JSON in/out. CSRF-exempt. **Server-to-server only** — no CORS `*`. Do not call
this from a browser.
---
## 1. Onboard a new caller
Two sides. A named token is the only lock. Anyone who has the URL still cannot
mint without it.
### 1a. This service (operator)
1. Generate a secret (do **not** reuse `DJANGO_SECRET_KEY` or a webhook secret):
```bash
python -c "import secrets; print(secrets.token_urlsafe(32))"
```
2. Pick a short token **name** for the caller (`monica`, `scha`, `chat`, …).
Revoking one name does not rotate the others.
3. Append `name:secret` to `SHORTENER_API_TOKENS` (comma-separated). Redeploy
or restart so settings reload.
```text
SHORTENER_API_TOKENS=monica:<secret>,scha:<other-secret>
```
Prod/beta files:
```text
~/Documents/secrets/url_shortening_service/url_shortening_service_prod.env
~/Documents/secrets/url_shortening_service/url_shortening_service_beta.env
```
4. If the callers destination hosts are not already allowed, add them to
`SHORT_ALLOWED_HOSTS` (exact or suffix). Example: `mkdrealtor.com` also
allows `www.mkdrealtor.com`. `https` only.
5. Give the caller **only**:
- API origin (`SHORTENER_BASE_URL`)
- The full token string `name:secret` (they send it as Bearer)
Never put the token in git, logs, or the short URL.
Empty `SHORTENER_API_TOKENS` → every `/api/` request is **503** (fail closed).
### 1b. Caller service (your repo)
Add to that apps env (not this repo):
```text
# Prod
SHORTENER_BASE_URL=https://shortener.aimloperations.com
SHORTENER_API_TOKEN=monica:<same-secret>
# Beta (when that host exists)
# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com
# SHORTENER_API_TOKEN=monica:<beta-secret>
# Local (this service via compose)
# SHORTENER_BASE_URL=http://127.0.0.1:8005
# SHORTENER_API_TOKEN=monica:dev-only-token
```
Send:
```http
Authorization: Bearer monica:<secret>
```
That value must match an entry in this services `SHORTENER_API_TOKENS`
(`name:secret`). Bare secret also works; prefer `name:secret`.
The token **name** (`monica`) is stored on the row as `created_by_token`.
The secret is never stored.
Attach UTM (or any query string) on `target_url` **before** you mint. The
short code is a pointer; it does not rewrite query params later.
---
## 2. Auth
Every `/api/` route requires:
```http
Authorization: Bearer <name>:<secret>
Content-Type: application/json
```
| Situation | Status | Body |
|-----------|--------|------|
| Missing or wrong Bearer | **401** | `{"detail":"Unauthorized"}` + `WWW-Authenticate: Bearer` |
| No tokens configured on the server | **503** | `{"detail":"Service unavailable"}` |
| Valid token | continues | — |
401 does not distinguish “unknown token” vs “malformed header”.
---
## 3. `POST /api/links/`
Mint a short link.
### Request
```http
POST /api/links/
Authorization: Bearer monica:<secret>
Content-Type: application/json
```
```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
}
```
| Field | Required | Notes |
|-------|----------|--------|
| `target_url` | yes | `https` only. Host must match `SHORT_ALLOWED_HOSTS`. No `http`, `javascript:`, `data:`, `//evil.com`, or `user:pass@host`. |
| `title` | no | Note, max 200 chars. |
| `external_ref` | no | Your id (campaign UUID), max 64. Empty → always mint a **new** code. |
| `expires_at` | no | ISO-8601 datetime or `null`. After this, `GET /<code>` is 404. |
### Response `201` (new)
```json
{
"code": "a3k9xm",
"short_url": "https://aiml.pw/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 this services `PUBLIC_SHORT_URL` (the domain phones
hit). It is **not** the API host. Put `short_url` in SMS / email as-is.
### Idempotency `200`
Same `target_url` + non-empty `external_ref` + still-active (and unexpired)
link → existing row, **200**, no new code.
Empty `external_ref` → always **201** and a new code.
### Errors
| Status | When |
|--------|------|
| 400 | Invalid JSON, bad URL, host not allowlisted, bad `expires_at` / title / ref |
| 401 | Auth |
| 503 | No tokens on server |
| 500 | Could not allocate a unique code (rare) |
There is no PATCH of `target_url` and no DELETE. Disable instead.
---
## 4. `GET /api/links/`
List. Query params:
| Param | Default | Notes |
|-------|---------|--------|
| `external_ref` | — | Exact match |
| `is_active` | — | `true` / `false` (or `1` / `0`) |
| `limit` | 20 | Capped at 100 |
| `offset` | 0 | |
```json
{
"count": 1,
"limit": 20,
"offset": 0,
"results": [ { "code": "a3k9xm", "short_url": "…", "…": "…" } ]
}
```
---
## 5. `GET /api/links/<code>/`
One link, including `click_count`. **404** if the code does not exist.
---
## 6. `POST /api/links/<code>/disable/`
Sets `is_active=false`. Idempotent. **200** with the updated row.
After disable, public `GET /<code>` is **404** (no redirect).
---
## 7. What the public does (not the API)
`GET https://<SHORT_DOMAIN>/<code>` — no token.
- Active + unexpired → **302** to `target_url` (not 301). Click counted.
- Missing / disabled / expired / bad shape → **404**.
- `GET /` → landing page.
Do not put the Bearer token on this URL.
---
## 8. Local smoke
This service:
```bash
cp .env.example .env # SHORTENER_API_TOKENS=monica:dev-only-token
docker compose up --build
```
Caller (or curl):
```bash
export SHORTENER_BASE_URL=http://127.0.0.1:8005
export SHORTENER_API_TOKEN=monica:dev-only-token
curl -sS -X POST "${SHORTENER_BASE_URL}/api/links/" \
-H "Authorization: Bearer ${SHORTENER_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"target_url":"https://mkdrealtor.com/","title":"test"}'
```
Expect `201` and `short_url` like `http://127.0.0.1:8005/<code>`.
```bash
curl -sSI "${SHORTENER_BASE_URL}/<code>"
```
Expect `302` and `Location: https://mkdrealtor.com/`.
No header → `401`. `https://evil.com` → `400`.
---
## 9. Minimal caller (Python)
```python
import os
import requests
BASE = os.environ["SHORTENER_BASE_URL"].rstrip("/")
TOKEN = os.environ["SHORTENER_API_TOKEN"]
def shorten(target_url: str, *, title: str = "", external_ref: str = "") -> str:
response = requests.post(
f"{BASE}/api/links/",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"target_url": target_url,
"title": title,
"external_ref": external_ref,
},
timeout=10,
)
response.raise_for_status()
return response.json()["short_url"]
```
Use `short_url` in the message body. On `401`/`503`, fail the send — do not
fall back to pasting the API host into SMS.
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/app/.venv
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY site ./site
COPY scripts/docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
WORKDIR /app/site
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
+504
View File
@@ -0,0 +1,504 @@
# 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 (for example `go.mkdrealtor.com`) is enough.
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 (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 /<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. |
### 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 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 (mkd.to / go.mkdrealtor.com)
│ 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 /<code> 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=go.mkdrealtor.com` (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 | 46 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://go.mkdrealtor.com/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 **and** API hostname |
| `DATABASE_URL` | Postgres; empty → SQLite |
| `SHORT_DOMAIN` | public hostname phones use (`go.mkdrealtor.com` then `mkd.to`) |
| `PUBLIC_SHORT_URL` | origin for minted URLs, e.g. `https://go.mkdrealtor.com` (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:<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 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 containers `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://<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`):
- `.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/<code>
```
+129
View File
@@ -1,2 +1,131 @@
# url_shortening_service
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
`GET /<code>` on the **short** domain and get a 302 to the long HTTPS URL.
This service is standalone. Do not fold it into `monica_site`.
## Two surfaces
| Who | Path | Auth | Host |
|-----|------|------|------|
| Phone / public internet | `GET /<code>` | none | `SHORT_DOMAIN` (NPM + TLS) |
| Anyone | `GET /` | none | landing page on the short domain |
| `monica_site` | `/api/links/` | `Authorization: Bearer name:secret` | own DNS / NPM host — **not** the short domain |
The API may be 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**.
`GET /<code>` never requires a token.
`/api/` and `/admin/` are 404 on the short domain. `/admin/` is also 404 on the
public API hostname (localhost only). `GET /debug/` is a mint form when
`DEBUG=true` and never on the short domain.
## Local run
```bash
cp .env.example .env
# SHORTENER_API_TOKENS=monica:dev-only-token is already set
docker compose up --build
```
Or without Docker:
```bash
uv sync
cp .env.example .env
cd site && uv run python manage.py migrate
uv run python manage.py runserver
# optional local admin:
# uv run python manage.py createsuperuser
# then http://127.0.0.1:8005/admin/ (compose) or :8000 (runserver)
```
Tests (SQLite, no network):
```bash
cd site && uv run python manage.py test
```
## CI / deploy (Gitea)
Same split as `monica_site`:
| Workflow | When | What |
|----------|------|------|
| `CI` | pull request → `master` | unit tests |
| `Deploy Beta` | push / merge to `master` | unit tests → compose tests → deploy **beta** |
| `Deploy Prod` | **Actions → Run workflow** (button) | unit tests → compose tests → deploy **prod** |
Deploy calls `server-infra/scripts/deploy.sh --app url_shortening_service`. Needs [server-infra#22](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/22) first.
## Use case
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).
3. A person on the public internet opens that short URL.
4. This service 302s them to the long URL and increments `click_count`.
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 (no token — this is the public path):
```bash
curl -sSI http://127.0.0.1:8005/<code>
```
Expect `HTTP/1.1 302 Found` and `Location: https://mkdrealtor.com/`.
Caller integration (onboard + `/api/links/`): **[API.md](API.md)**.
## Caller contract (`monica_site`, other repo)
```text
SHORTENER_BASE_URL=https://shortener.aimloperations.com
SHORTENER_API_TOKEN=monica:<same-secret-as-SHORTENER_API_TOKENS>
```
```http
POST /api/links/
Authorization: Bearer monica:<secret>
Content-Type: application/json
{"target_url":"https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms","title":"Oak St","external_ref":"campaign-uuid"}
```
`target_url` must be `https` and its host must match `SHORT_ALLOWED_HOSTS`
(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.
Do not call the public short hostname to create links.
## Environment
See `.env.example` and `.env.prod.example`. Prod secrets live in
`~/Documents/secrets/url_shortening_service/` on the control node.
Generate tokens and peppers with:
```bash
python -c "import secrets; print(secrets.token_urlsafe(32))"
```
Never reuse `DJANGO_SECRET_KEY` as an API token. Never put the token in the
short URL, logs, or git.
## Deploy notes
Two NPM hosts, same container `WEB_PORT` (default 8005 prod / 8015 beta):
1. `SHORT_DOMAIN` (`aiml.pw` / `cidinn.li`) — `/` landing + `GET /[a-z0-9]{4,8}`.
Drop `/api/`, `/admin/`, `/debug/`.
2. API hostname — proxy `/api/` only. Drop `/admin/`. Add that Host to
`DJANGO_ALLOWED_HOSTS` and `SHORT_API_HOSTS`.
+10
View File
@@ -0,0 +1,10 @@
# Production compose for server-infra deploy. No bundled Postgres — use shared
# external DB via DATABASE_URL in .env (see .env.prod.example).
services:
web:
build: .
restart: unless-stopped
ports:
- "${WEB_PORT:-8005}:8000"
env_file:
- .env
+46
View File
@@ -0,0 +1,46 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: url_shortener
POSTGRES_USER: url_shortener
POSTGRES_PASSWORD: url_shortener
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U url_shortener -d url_shortener"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
web:
build: .
ports:
- "127.0.0.1:8005:8000"
volumes:
- ./site:/app/site
environment:
DJANGO_ENV: ${DJANGO_ENV:-dev}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,url-shortener}
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://url_shortener:url_shortener@db:5432/url_shortener}
SHORT_DOMAIN: ${SHORT_DOMAIN:-localhost:8005}
PUBLIC_SHORT_URL: ${PUBLIC_SHORT_URL:-http://127.0.0.1:8005}
SHORT_PUBLIC_HOSTS: ${SHORT_PUBLIC_HOSTS:-go.mkdrealtor.com}
SHORT_API_HOSTS: ${SHORT_API_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,url-shortener}
SHORTENER_API_TOKENS: ${SHORTENER_API_TOKENS:-monica:dev-only-token}
SHORT_ALLOWED_HOSTS: ${SHORT_ALLOWED_HOSTS:-mkdrealtor.com,aimloperations.com}
SHORT_CODE_LENGTH: ${SHORT_CODE_LENGTH:-6}
CLICK_IP_PEPPER: ${CLICK_IP_PEPPER:-dev-click-pepper-change-me}
networks:
default:
aliases:
- url-shortener
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:
+15
View File
@@ -0,0 +1,15 @@
[project]
name = "url-shortening-service"
version = "0.1.0"
description = "Django URL shortener for MKDRealtor / monica_site"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"django>=6.0,<7",
"gunicorn>=23.0.0",
"psycopg[binary]>=3.3.3",
"whitenoise>=6.9.0",
]
[tool.uv]
package = false
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
cd /app/site
wait_for_database() {
if [[ -z "${DATABASE_URL:-}" && -z "${DB_HOST:-}" ]]; then
return 0
fi
echo "Waiting for database..."
for _ in $(seq 1 30); do
if uv run python - <<'PY'
import os
import sys
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shortener.settings")
import django
from django.db import connections
from django.db.utils import OperationalError
django.setup()
try:
connections["default"].ensure_connection()
except OperationalError:
sys.exit(1)
PY
then
echo "Database is ready."
return 0
fi
sleep 2
done
echo "Database did not become ready in time." >&2
exit 1
}
wait_for_database
uv run python manage.py migrate --noinput
# Local compose defaults to DJANGO_ENV=dev: runserver + source bind-mount → hot reload.
# Prod/beta images keep gunicorn (no file watch).
if [[ "${DJANGO_ENV:-}" == "dev" || "${DJANGO_USE_RUNSERVER:-}" == "true" ]]; then
echo "Starting Django runserver (auto-reload on)."
exec uv run python manage.py runserver "${GUNICORN_BIND:-0.0.0.0:8000}"
fi
uv run python manage.py collectstatic --noinput
exec uv run gunicorn shortener.wsgi:application \
--bind "${GUNICORN_BIND:-0.0.0.0:8000}" \
--workers "${GUNICORN_WORKERS:-2}"
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "core"
+14
View File
@@ -0,0 +1,14 @@
from django.conf import settings
from links.middleware import is_public_host
def branding(request):
return {
"SITE_NAME": settings.SITE_NAME,
"SHORT_DOMAIN": settings.SHORT_DOMAIN,
"PUBLIC_SHORT_URL": settings.PUBLIC_SHORT_URL,
"CREDIT_NAME": settings.CREDIT_NAME,
"CREDIT_URL": settings.CREDIT_URL,
"show_debug_create": settings.DEBUG and not is_public_host(request.get_host()),
}
+128
View File
@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ SITE_NAME }}{% endblock %}</title>
<style>
:root {
--ink: #1c1914;
--paper: #f3ead8;
--rule: #c9b896;
--pine: #2a4a3c;
--rust: #9a3f24;
--muted: #6b6254;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
background: var(--paper);
color: var(--ink);
font-family: "Iowan Old Style", Palatino, "Palatino Linotype", "Times New Roman", serif;
line-height: 1.5;
}
.shell {
max-width: 40rem;
margin: 0 auto;
padding: 3.5rem 1.5rem 2rem;
}
.eyebrow {
font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--pine);
margin: 0 0 0.75rem;
}
h1 {
font-size: clamp(2.4rem, 8vw, 4.2rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 0.95;
margin: 0 0 1.25rem;
}
p { margin: 0 0 1rem; font-size: 1.15rem; }
.lede { color: var(--muted); max-width: 32rem; }
a { color: var(--rust); }
a:hover { color: var(--ink); }
footer {
margin-top: 3.5rem;
padding-top: 1rem;
border-top: 1px solid var(--rule);
font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
font-size: 0.78rem;
color: var(--muted);
}
footer a { color: inherit; }
.banner {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--ink);
color: var(--paper);
padding: 0.4rem 1.5rem;
}
form { margin-top: 1.75rem; display: grid; gap: 0.9rem; }
label {
display: grid;
gap: 0.3rem;
font-family: ui-monospace, Menlo, monospace;
font-size: 0.78rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--pine);
}
input[type="url"], input[type="text"] {
font: 1rem/1.4 "Iowan Old Style", Palatino, serif;
padding: 0.55rem 0.65rem;
border: 1px solid var(--rule);
background: #fffdf6;
color: var(--ink);
}
button, .btn {
appearance: none;
border: 0;
background: var(--pine);
color: var(--paper);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.7rem 1rem;
cursor: pointer;
text-decoration: none;
display: inline-block;
width: fit-content;
}
button:hover, .btn:hover { background: var(--ink); }
.error { color: var(--rust); font-size: 0.95rem; }
.result {
margin-top: 1.5rem;
padding: 1rem;
border: 1px dashed var(--pine);
background: #fffdf6;
}
.result code {
font-size: 1.05rem;
word-break: break-all;
}
.messages { list-style: none; padding: 0; margin: 0 0 1rem; color: var(--pine); }
</style>
</head>
<body>
{% if debug %}
<div class="banner">Debug — create page is local only</div>
{% endif %}
<main class="shell">
{% block content %}{% endblock %}
<footer>
Created by
<a href="{{ CREDIT_URL }}">{{ CREDIT_NAME }}</a>
{% if show_debug_create %}
· <a href="{% url 'debug-create' %}">Create a short link</a>
{% endif %}
</footer>
</main>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
{% extends "core/base.html" %}
{% block title %}{{ SITE_NAME }}{% endblock %}
{% block content %}
<p class="eyebrow">{{ SITE_NAME }}</p>
<h1>{{ SHORT_DOMAIN }}</h1>
<p class="lede">
This is a URL shortening service. A path like
<strong>/a3k9xm</strong> sends you to the long HTTPS URL.
</p>
<p class="lede">
Created by <a href="{{ CREDIT_URL }}">{{ CREDIT_NAME }}</a>
· <a href="{{ CREDIT_URL }}">aimloperations.com</a>
</p>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
from django.http import JsonResponse
from django.shortcuts import render
def healthz(_request):
"""Liveness probe for deploy / NPM health checks."""
return JsonResponse({"status": "ok"})
def landing(request):
"""Public root: this is a URL shortening service, not an API index."""
return render(request, "core/landing.html")
+1
View File
@@ -0,0 +1 @@
+158
View File
@@ -0,0 +1,158 @@
from django.contrib import admin, messages
from django.forms import ModelForm, ValidationError as FormValidationError
from django.utils.html import format_html
from links.models import Click, ShortLink
from links.services import ValidationError, generate_code, validate_target_url
admin.site.site_header = "URL shortener"
admin.site.site_title = "Shortener admin"
admin.site.index_title = "Links and clicks"
class ShortLinkAdminForm(ModelForm):
class Meta:
model = ShortLink
fields = "__all__"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if "code" in self.fields:
self.fields["code"].required = False
if "created_by_token" in self.fields:
self.fields["created_by_token"].required = False
def clean_target_url(self):
raw = self.cleaned_data.get("target_url") or ""
try:
return validate_target_url(raw)
except ValidationError as exc:
raise FormValidationError(str(exc)) from exc
def clean(self):
cleaned = super().clean()
if not self.instance.pk and not cleaned.get("code"):
cleaned["code"] = generate_code()
if not cleaned.get("created_by_token"):
cleaned["created_by_token"] = "admin"
return cleaned
class ClickInline(admin.TabularInline):
model = Click
extra = 0
can_delete = False
show_change_link = True
max_num = 0
readonly_fields = ("occurred_at", "ip_hash", "user_agent", "referrer")
fields = ("occurred_at", "ip_hash", "user_agent", "referrer")
def has_add_permission(self, request, obj=None):
return False
@admin.register(ShortLink)
class ShortLinkAdmin(admin.ModelAdmin):
form = ShortLinkAdminForm
list_display = (
"code",
"short_url_display",
"target_url",
"title",
"is_active",
"click_count",
"created_by_token",
"expires_at",
"created_at",
)
list_filter = ("is_active", "created_by_token", "created_at")
search_fields = ("code", "target_url", "external_ref", "title")
date_hierarchy = "created_at"
ordering = ("-created_at",)
list_per_page = 50
actions = ("disable_links",)
inlines = (ClickInline,)
readonly_fields = (
"id",
"code",
"created_by_token",
"click_count",
"created_at",
"updated_at",
"short_url_display",
)
fieldsets = (
(
None,
{
"fields": (
"code",
"short_url_display",
"target_url",
"title",
"external_ref",
"is_active",
"expires_at",
)
},
),
(
"Attribution",
{"fields": ("created_by_token", "click_count", "created_at", "updated_at", "id")},
),
)
def get_readonly_fields(self, request, obj=None):
if obj:
return self.readonly_fields + ("target_url",)
return self.readonly_fields
@admin.display(description="Short URL")
def short_url_display(self, obj: ShortLink) -> str:
if not obj.code:
return ""
return format_html('<a href="{0}">{0}</a>', obj.public_short_url)
def save_model(self, request, obj, form, change):
if not change:
if not obj.code:
obj.code = generate_code()
if not obj.created_by_token:
obj.created_by_token = (
request.user.get_username() if request.user.is_authenticated else "admin"
)
super().save_model(request, obj, form, change)
@admin.action(description="Disable selected links")
def disable_links(self, request, queryset):
updated = queryset.filter(is_active=True).update(is_active=False)
self.message_user(
request,
f"Disabled {updated} link(s).",
messages.SUCCESS if updated else messages.INFO,
)
@admin.register(Click)
class ClickAdmin(admin.ModelAdmin):
list_display = ("link", "occurred_at", "ip_hash_short", "user_agent_short")
list_filter = ("occurred_at",)
search_fields = ("link__code", "ip_hash", "user_agent", "referrer")
date_hierarchy = "occurred_at"
readonly_fields = ("id", "link", "occurred_at", "ip_hash", "user_agent", "referrer")
ordering = ("-occurred_at",)
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
@admin.display(description="IP hash")
def ip_hash_short(self, obj: Click) -> str:
return (obj.ip_hash[:12] + "") if obj.ip_hash else ""
@admin.display(description="User agent")
def user_agent_short(self, obj: Click) -> str:
ua = obj.user_agent or ""
return (ua[:48] + "") if len(ua) > 48 else (ua or "")
+156
View File
@@ -0,0 +1,156 @@
"""Internal JSON API for minting and managing short links."""
from __future__ import annotations
import json
from django.http import Http404, HttpRequest, JsonResponse
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from links.auth import require_bearer
from links.models import ShortLink
from links.services import (
CodeCollisionError,
ValidationError,
create_link,
parse_expires_at,
validate_target_url,
)
def _json_body(request: HttpRequest) -> dict | None:
if not request.body:
return {}
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return None
if not isinstance(data, dict):
return None
return data
def _iso(dt) -> str | None:
if dt is None:
return None
return dt.isoformat().replace("+00:00", "Z")
def serialize_link(link: ShortLink) -> dict:
from django.conf import settings
origin = (settings.PUBLIC_SHORT_URL or "").rstrip("/")
return {
"code": link.code,
"short_url": f"{origin}/{link.code}",
"target_url": link.target_url,
"title": link.title,
"is_active": link.is_active,
"click_count": link.click_count,
"created_at": _iso(link.created_at),
}
@require_bearer
@require_http_methods(["GET", "POST"])
def links_collection(request: HttpRequest):
if request.method == "POST":
return _create(request)
return _list(request)
def _create(request: HttpRequest) -> JsonResponse:
data = _json_body(request)
if data is None:
return JsonResponse({"detail": "invalid json"}, status=400)
raw_url = data.get("target_url")
try:
target_url = validate_target_url(raw_url if isinstance(raw_url, str) else "")
expires_at = parse_expires_at(data.get("expires_at"))
except ValidationError as exc:
return JsonResponse({"detail": str(exc)}, status=400)
title = data.get("title") or ""
if not isinstance(title, str):
return JsonResponse({"detail": "invalid title"}, status=400)
title = title[:200]
external_ref = data.get("external_ref") or ""
if not isinstance(external_ref, str):
return JsonResponse({"detail": "invalid external_ref"}, status=400)
external_ref = external_ref[:64]
try:
link, created = create_link(
target_url=target_url,
title=title,
external_ref=external_ref,
expires_at=expires_at,
token_name=request.token_name,
)
except CodeCollisionError:
return JsonResponse({"detail": "could not allocate a unique code"}, status=500)
return JsonResponse(serialize_link(link), status=201 if created else 200)
def _list(request: HttpRequest) -> JsonResponse:
qs = ShortLink.objects.all()
external_ref = request.GET.get("external_ref")
if external_ref is not None:
qs = qs.filter(external_ref=external_ref)
is_active = request.GET.get("is_active")
if is_active is not None:
lowered = is_active.lower()
if lowered in {"true", "1"}:
qs = qs.filter(is_active=True)
elif lowered in {"false", "0"}:
qs = qs.filter(is_active=False)
else:
return JsonResponse({"detail": "invalid is_active"}, status=400)
try:
limit = int(request.GET.get("limit", 20))
offset = int(request.GET.get("offset", 0))
except (TypeError, ValueError):
return JsonResponse({"detail": "invalid pagination"}, status=400)
limit = min(max(limit, 0), 100)
offset = max(offset, 0)
total = qs.count()
rows = list(qs[offset : offset + limit])
return JsonResponse(
{
"count": total,
"limit": limit,
"offset": offset,
"results": [serialize_link(link) for link in rows],
}
)
@require_bearer
@require_GET
def link_detail(request: HttpRequest, code: str):
try:
link = ShortLink.objects.get(code=code)
except ShortLink.DoesNotExist as exc:
raise Http404() from exc
return JsonResponse(serialize_link(link))
@require_bearer
@require_POST
def link_disable(request: HttpRequest, code: str):
try:
link = ShortLink.objects.get(code=code)
except ShortLink.DoesNotExist as exc:
raise Http404() from exc
if link.is_active:
link.is_active = False
link.save(update_fields=["is_active", "updated_at"])
return JsonResponse(serialize_link(link))
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class LinksConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "links"
+64
View File
@@ -0,0 +1,64 @@
"""Bearer token auth for /api/. The lock that keeps a public API host closed."""
from __future__ import annotations
import hmac
from collections.abc import Callable
from functools import wraps
from django.conf import settings
from django.http import HttpRequest, JsonResponse
from django.views.decorators.csrf import csrf_exempt
def parse_bearer(request: HttpRequest) -> str | None:
header = request.META.get("HTTP_AUTHORIZATION") or ""
if not header.startswith("Bearer "):
return None
token = header[7:].strip()
return token or None
def authenticate_token(raw_token: str | None) -> str | None:
"""Return the configured token name, or None if no match.
Accepts either ``name:secret`` (as callers send) or the bare secret.
Always compares against every configured token (constant-time).
"""
configured = list(getattr(settings, "SHORTENER_API_TOKENS", []) or [])
if not configured or not raw_token:
return None
matched_name: str | None = None
for name, secret in configured:
full = f"{name}:{secret}"
if hmac.compare_digest(raw_token, full) or hmac.compare_digest(raw_token, secret):
matched_name = name
# Keep looping so compare_digest runs for every token.
return matched_name
def token_name_for_request(request: HttpRequest) -> str | None:
return authenticate_token(parse_bearer(request))
def require_bearer(view: Callable) -> Callable:
"""Decorator: 503 if no tokens configured, 401 if missing/wrong Bearer."""
@csrf_exempt
@wraps(view)
def wrapper(request, *args, **kwargs):
configured = list(getattr(settings, "SHORTENER_API_TOKENS", []) or [])
if not configured:
return JsonResponse({"detail": "Service unavailable"}, status=503)
name = token_name_for_request(request)
if not name:
response = JsonResponse({"detail": "Unauthorized"}, status=401)
response["WWW-Authenticate"] = "Bearer"
return response
request.token_name = name
return view(request, *args, **kwargs)
return wrapper
+41
View File
@@ -0,0 +1,41 @@
"""DEBUG-only form to mint a short link without curling the API."""
from django.conf import settings
from django.contrib import messages
from django.http import Http404
from django.shortcuts import render
from django.views.decorators.http import require_http_methods
from links.forms import DebugCreateForm
from links.services import CodeCollisionError, create_link
@require_http_methods(["GET", "POST"])
def debug_create(request):
if not settings.DEBUG:
raise Http404()
form = DebugCreateForm(request.POST or None)
created_link = None
if request.method == "POST" and form.is_valid():
try:
created_link, minted = create_link(
target_url=form.cleaned_data["target_url"],
title=form.cleaned_data.get("title") or "",
external_ref=form.cleaned_data.get("external_ref") or "",
expires_at=None,
token_name="debug",
)
except CodeCollisionError:
form.add_error(None, "Could not allocate a unique code.")
else:
if minted:
messages.success(request, "Short link created.")
else:
messages.info(request, "Existing active link returned (same URL + ref).")
return render(
request,
"links/debug_create.html",
{"form": form, "created_link": created_link},
)
+21
View File
@@ -0,0 +1,21 @@
from django import forms
from links.services import ValidationError, validate_target_url
class DebugCreateForm(forms.Form):
target_url = forms.URLField(
label="Target URL",
widget=forms.URLInput(
attrs={"placeholder": "https://mkdrealtor.com/listings/oak-st", "autofocus": True}
),
)
title = forms.CharField(label="Title", required=False, max_length=200)
external_ref = forms.CharField(label="External ref", required=False, max_length=64)
def clean_target_url(self) -> str:
raw = self.cleaned_data["target_url"]
try:
return validate_target_url(raw)
except ValidationError as exc:
raise forms.ValidationError(str(exc)) from exc
+68
View File
@@ -0,0 +1,68 @@
"""Keep the short domain and Django admin off the public API hostname."""
from __future__ import annotations
from django.conf import settings
from django.http import Http404, HttpRequest
def _normalize_host(host: str) -> str:
return host.split(":")[0].lower().rstrip(".")
def _host_in(host: str, configured: list[str]) -> bool:
needle = _normalize_host(host)
raw = host.lower()
for entry in configured:
if not entry:
continue
if raw == entry.lower() or needle == _normalize_host(entry):
return True
return False
def is_api_host(host: str) -> bool:
return _host_in(host, list(getattr(settings, "SHORT_API_HOSTS", []) or []))
def is_public_host(host: str) -> bool:
return _host_in(host, list(getattr(settings, "SHORT_PUBLIC_HOSTS", []) or []))
def is_admin_host(host: str) -> bool:
return _host_in(host, list(getattr(settings, "SHORT_ADMIN_HOSTS", []) or []))
class HostSplitMiddleware:
"""Short host = redirects only. API host = /api/ (Bearer). Admin = local only.
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.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request: HttpRequest):
path = request.path
if path in {"/healthz", "/healthz/"}:
return self.get_response(request)
host = request.get_host()
if path.startswith("/debug"):
if not settings.DEBUG or is_public_host(host):
raise Http404()
return self.get_response(request)
if path.startswith("/admin"):
if not is_admin_host(host):
raise Http404()
return self.get_response(request)
if path.startswith("/api/"):
# Short redirect hostname never serves the API, even if mis-listed.
if is_public_host(host) or not is_api_host(host):
raise Http404()
return self.get_response(request)
+49
View File
@@ -0,0 +1,49 @@
# Generated by Django 6.1 on 2026-08-30 10:09
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ShortLink',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(db_index=True, max_length=8, unique=True)),
('target_url', models.URLField(max_length=2048)),
('title', models.CharField(blank=True, max_length=200)),
('created_by_token', models.CharField(max_length=64)),
('external_ref', models.CharField(blank=True, db_index=True, max_length=64)),
('is_active', models.BooleanField(default=True)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('click_count', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Click',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('occurred_at', models.DateTimeField(auto_now_add=True)),
('ip_hash', models.CharField(blank=True, max_length=64)),
('user_agent', models.CharField(blank=True, max_length=512)),
('referrer', models.CharField(blank=True, max_length=1024)),
('link', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clicks', to='links.shortlink')),
],
options={
'ordering': ['-occurred_at'],
},
),
]
View File
+53
View File
@@ -0,0 +1,53 @@
import uuid
from django.db import models
from django.utils import timezone
class ShortLink(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
code = models.CharField(max_length=8, unique=True, db_index=True)
target_url = models.URLField(max_length=2048)
title = models.CharField(max_length=200, blank=True)
created_by_token = models.CharField(max_length=64)
external_ref = models.CharField(max_length=64, blank=True, db_index=True)
is_active = models.BooleanField(default=True)
expires_at = models.DateTimeField(null=True, blank=True)
click_count = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return self.code
def is_available(self) -> bool:
if not self.is_active:
return False
if self.expires_at is not None and timezone.now() >= self.expires_at:
return False
return True
@property
def public_short_url(self) -> str:
from django.conf import settings
origin = (getattr(settings, "PUBLIC_SHORT_URL", "") or "").rstrip("/")
return f"{origin}/{self.code}"
class Click(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
link = models.ForeignKey(ShortLink, on_delete=models.CASCADE, related_name="clicks")
occurred_at = models.DateTimeField(auto_now_add=True)
ip_hash = models.CharField(max_length=64, blank=True)
user_agent = models.CharField(max_length=512, blank=True)
referrer = models.CharField(max_length=1024, blank=True)
class Meta:
ordering = ["-occurred_at"]
def __str__(self) -> str:
return f"{self.link.code} @ {self.occurred_at}"
+179
View File
@@ -0,0 +1,179 @@
"""Link minting, target-URL allowlist, click recording."""
from __future__ import annotations
import hashlib
import hmac
import logging
import secrets
from datetime import datetime
from urllib.parse import urlsplit, urlunsplit
from django.conf import settings
from django.db import IntegrityError
from django.db.models import F
from django.http import HttpRequest
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from links.models import Click, ShortLink
logger = logging.getLogger(__name__)
MAX_CODE_ATTEMPTS = 8
class ValidationError(ValueError):
pass
class CodeCollisionError(RuntimeError):
pass
def host_allowed(hostname: str, allowed: list[str]) -> bool:
hostname = hostname.lower().rstrip(".")
for entry in allowed:
entry = entry.lower().strip()
if entry.startswith("*."):
entry = entry[2:]
entry = entry.lstrip(".").rstrip(".")
if not entry:
continue
if hostname == entry or hostname.endswith("." + entry):
return True
return False
def validate_target_url(raw: str) -> str:
"""Return a canonical https URL or raise ValidationError."""
if not raw or not isinstance(raw, str):
raise ValidationError("invalid url")
raw = raw.strip()
if raw.startswith("//"):
raise ValidationError("invalid url")
try:
parts = urlsplit(raw)
except ValueError as exc:
raise ValidationError("invalid url") from exc
if parts.scheme.lower() != "https":
raise ValidationError("invalid url")
if parts.username or parts.password:
raise ValidationError("invalid url")
hostname = (parts.hostname or "").lower().rstrip(".")
if not hostname:
raise ValidationError("invalid url")
allowed = list(getattr(settings, "SHORT_ALLOWED_HOSTS", []) or [])
if not host_allowed(hostname, allowed):
raise ValidationError("host not allowlisted")
netloc = hostname
if parts.port:
netloc = f"{hostname}:{parts.port}"
return urlunsplit(("https", netloc, parts.path, parts.query, parts.fragment))
def generate_code(length: int | None = None) -> str:
alphabet = settings.CODE_ALPHABET
size = length if length is not None else settings.SHORT_CODE_LENGTH
return "".join(secrets.choice(alphabet) for _ in range(size))
def mint_unique_code() -> str:
for _ in range(MAX_CODE_ATTEMPTS):
code = generate_code()
if not ShortLink.objects.filter(code=code).exists():
return code
raise CodeCollisionError("could not allocate a unique code")
def parse_expires_at(value) -> datetime | None:
if value in (None, ""):
return None
if not isinstance(value, str):
raise ValidationError("invalid expires_at")
parsed = parse_datetime(value)
if parsed is None:
raise ValidationError("invalid expires_at")
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
return parsed
def find_idempotent_link(target_url: str, external_ref: str) -> ShortLink | None:
if not external_ref:
return None
qs = ShortLink.objects.filter(
target_url=target_url,
external_ref=external_ref,
is_active=True,
)
now = timezone.now()
for link in qs:
if link.expires_at is None or link.expires_at > now:
return link
return None
def create_link(
*,
target_url: str,
title: str,
external_ref: str,
expires_at: datetime | None,
token_name: str,
) -> tuple[ShortLink, bool]:
"""Return ``(link, created)``. ``created`` is False on idempotent hit."""
existing = find_idempotent_link(target_url, external_ref)
if existing:
return existing, False
for _ in range(MAX_CODE_ATTEMPTS):
try:
link = ShortLink.objects.create(
code=generate_code(),
target_url=target_url,
title=title,
external_ref=external_ref,
expires_at=expires_at,
created_by_token=token_name,
)
return link, True
except IntegrityError:
continue
raise CodeCollisionError("could not allocate a unique code")
def client_ip(request: HttpRequest) -> str:
forwarded = request.META.get("HTTP_X_FORWARDED_FOR") or ""
if forwarded:
return forwarded.split(",")[0].strip()
return (request.META.get("REMOTE_ADDR") or "").strip()
def hash_ip(ip: str) -> str:
pepper = getattr(settings, "CLICK_IP_PEPPER", "") or ""
if not ip or not pepper:
return ""
return hmac.new(pepper.encode(), ip.encode(), hashlib.sha256).hexdigest()
def record_click(request: HttpRequest, link: ShortLink) -> None:
Click.objects.create(
link=link,
ip_hash=hash_ip(client_ip(request)),
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:512],
referrer=(request.META.get("HTTP_REFERER") or "")[:1024],
)
ShortLink.objects.filter(pk=link.pk).update(click_count=F("click_count") + 1)
def record_click_best_effort(request: HttpRequest, link: ShortLink) -> None:
try:
record_click(request, link)
except Exception:
logger.exception("click record failed for code=%s", link.code)
@@ -0,0 +1,35 @@
{% extends "core/base.html" %}
{% block title %}Create short link · debug{% endblock %}
{% block content %}
<p class="eyebrow">Debug only</p>
<h1>Create a short link</h1>
<p class="lede">Uses the same allowlist as the API. Not served when <code>DEBUG</code> is false.</p>
{% if messages %}
<ul class="messages">
{% for message in messages %}<li>{{ message }}</li>{% endfor %}
</ul>
{% endif %}
{% if created_link %}
<div class="result">
<p>Short URL</p>
<p><code>{{ created_link.public_short_url }}</code></p>
<p>Target: {{ created_link.target_url }}</p>
<a class="btn" href="{{ created_link.public_short_url }}">Open</a>
</div>
{% endif %}
<form method="post" action="{% url 'debug-create' %}">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<label>
{{ field.label }}
{{ field }}
{% if field.errors %}<span class="error">{{ field.errors|striptags }}</span>{% endif %}
</label>
{% endfor %}
<button type="submit">Mint</button>
</form>
{% endblock %}
+500
View File
@@ -0,0 +1,500 @@
"""Tests required by Implementation.md §12."""
import json
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.utils import timezone
from django.conf import settings
from links.models import Click, ShortLink
User = get_user_model()
AUTH = "Bearer monica:dev-only-token"
TOKENS = [("monica", "dev-only-token")]
SETTINGS = dict(
SHORTENER_API_TOKENS=TOKENS,
PUBLIC_SHORT_URL="https://go.mkdrealtor.com",
SHORT_PUBLIC_HOSTS=["go.mkdrealtor.com"],
SHORT_API_HOSTS=["testserver", "localhost", "127.0.0.1", "shortener.example.com"],
SHORT_ADMIN_HOSTS=["localhost", "127.0.0.1"],
SHORT_ALLOWED_HOSTS=["mkdrealtor.com"],
CLICK_IP_PEPPER="test-pepper-not-the-secret-key",
ALLOWED_HOSTS=[
"testserver",
"localhost",
"127.0.0.1",
"go.mkdrealtor.com",
"shortener.example.com",
],
SHORT_CODE_LENGTH=6,
)
def _json(response):
return json.loads(response.content.decode())
@override_settings(**SETTINGS)
class AuthTests(TestCase):
def test_missing_bearer_401(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response["WWW-Authenticate"], "Bearer")
def test_wrong_token_401(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer monica:wrong-secret",
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response["WWW-Authenticate"], "Bearer")
self.assertEqual(_json(response)["detail"], "Unauthorized")
def test_matching_named_token_201(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
)
self.assertEqual(response.status_code, 201)
body = _json(response)
self.assertEqual(body["target_url"], "https://mkdrealtor.com/x")
self.assertTrue(body["is_active"])
self.assertEqual(body["click_count"], 0)
@override_settings(SHORTENER_API_TOKENS=[])
def test_empty_tokens_503(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
)
self.assertEqual(response.status_code, 503)
@override_settings(**SETTINGS)
class HostSplitTests(TestCase):
def test_public_host_api_404_even_with_bearer(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="go.mkdrealtor.com",
)
self.assertEqual(response.status_code, 404)
@override_settings(
SHORT_API_HOSTS=[
"testserver",
"localhost",
"127.0.0.1",
"shortener.example.com",
"go.mkdrealtor.com",
]
)
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="go.mkdrealtor.com",
)
self.assertEqual(response.status_code, 404)
def test_public_host_admin_404(self):
response = self.client.get("/admin/", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
def test_public_api_host_without_bearer_401(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_HOST="shortener.example.com",
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response["WWW-Authenticate"], "Bearer")
def test_public_api_host_wrong_token_401(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer monica:wrong-secret",
HTTP_HOST="shortener.example.com",
)
self.assertEqual(response.status_code, 401)
def test_public_api_host_valid_bearer_201(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="shortener.example.com",
)
self.assertEqual(response.status_code, 201)
def test_public_api_host_admin_404(self):
response = self.client.get("/admin/", HTTP_HOST="shortener.example.com")
self.assertEqual(response.status_code, 404)
def test_healthz_on_public_and_api(self):
for host in ("go.mkdrealtor.com", "testserver", "shortener.example.com"):
response = self.client.get("/healthz/", HTTP_HOST=host)
self.assertEqual(response.status_code, 200)
self.assertEqual(_json(response), {"status": "ok"})
@override_settings(**SETTINGS)
class AllowlistTests(TestCase):
def _post(self, target_url):
return self.client.post(
"/api/links/",
data=json.dumps({"target_url": target_url}),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
)
def test_https_allowlisted_ok(self):
self.assertEqual(self._post("https://mkdrealtor.com/x").status_code, 201)
self.assertEqual(self._post("https://www.mkdrealtor.com/x").status_code, 201)
@override_settings(SHORT_ALLOWED_HOSTS=["mkdrealtor.com", "*.aimloperations.com"])
def test_glob_suffix_and_apex(self):
self.assertEqual(
self._post("https://aimloperations.com/web_design").status_code, 201
)
self.assertEqual(
self._post("https://www.aimloperations.com/web_design").status_code, 201
)
def test_http_rejected(self):
self.assertEqual(self._post("http://mkdrealtor.com/x").status_code, 400)
def test_evil_host_rejected(self):
self.assertEqual(self._post("https://evil.com").status_code, 400)
def test_javascript_rejected(self):
self.assertEqual(self._post("javascript:alert(1)").status_code, 400)
def test_protocol_relative_rejected(self):
self.assertEqual(self._post("//evil.com").status_code, 400)
def test_credentials_rejected(self):
self.assertEqual(
self._post("https://user:pass@mkdrealtor.com/x").status_code, 400
)
def test_suffix_does_not_match_cousin_domain(self):
self.assertEqual(self._post("https://notmkdrealtor.com/x").status_code, 400)
@override_settings(**SETTINGS)
class RedirectTests(TestCase):
def setUp(self):
self.link = ShortLink.objects.create(
code="a3k9xm",
target_url="https://mkdrealtor.com/listings/oak-st",
created_by_token="monica",
)
def test_active_code_302_to_target(self):
response = self.client.get(
"/a3k9xm", HTTP_HOST="go.mkdrealtor.com", follow=False
)
self.assertEqual(response.status_code, 302)
self.assertNotEqual(response.status_code, 301)
self.assertEqual(
response["Location"], "https://mkdrealtor.com/listings/oak-st"
)
def test_head_also_302_without_click(self):
response = self.client.head(
"/a3k9xm", HTTP_HOST="go.mkdrealtor.com", follow=False
)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response["Location"], "https://mkdrealtor.com/listings/oak-st"
)
self.link.refresh_from_db()
self.assertEqual(self.link.click_count, 0)
self.assertEqual(Click.objects.filter(link=self.link).count(), 0)
def test_inactive_404(self):
self.link.is_active = False
self.link.save()
response = self.client.get("/a3k9xm", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
def test_expired_404(self):
self.link.expires_at = timezone.now() - timedelta(minutes=1)
self.link.save()
response = self.client.get("/a3k9xm", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
def test_unknown_404(self):
response = self.client.get("/zzzzzz", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
def test_invalid_code_shape_404(self):
response = self.client.get("/AB", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
def test_public_root_is_landing(self):
response = self.client.get("/", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "URL Shortening Service")
self.assertContains(response, "aimloperations.com")
self.assertNotContains(response, "Create a short link")
def test_click_row_and_count(self):
self.client.get(
"/a3k9xm",
HTTP_HOST="go.mkdrealtor.com",
HTTP_USER_AGENT="sms-client",
)
self.link.refresh_from_db()
self.assertEqual(self.link.click_count, 1)
self.assertEqual(Click.objects.filter(link=self.link).count(), 1)
click = Click.objects.get(link=self.link)
self.assertEqual(click.user_agent, "sms-client")
self.assertTrue(click.ip_hash)
self.assertNotIn("127.0.0.1", click.ip_hash)
@override_settings(**SETTINGS)
class CreateTests(TestCase):
def _post(self, payload, **headers):
return self.client.post(
"/api/links/",
data=json.dumps(payload),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
**headers,
)
def test_short_url_uses_public_origin_not_api_host(self):
response = self._post(
{"target_url": "https://mkdrealtor.com/x"},
HTTP_HOST="testserver",
)
self.assertEqual(response.status_code, 201)
body = _json(response)
self.assertTrue(body["short_url"].startswith("https://go.mkdrealtor.com/"))
self.assertNotIn("testserver", body["short_url"])
def test_idempotent_same_target_and_external_ref(self):
payload = {
"target_url": "https://mkdrealtor.com/listings/oak-st?utm_source=monica",
"title": "Oak St",
"external_ref": "campaign-1",
}
first = self._post(payload)
second = self._post(payload)
self.assertEqual(first.status_code, 201)
self.assertEqual(second.status_code, 200)
self.assertEqual(_json(first)["code"], _json(second)["code"])
self.assertEqual(ShortLink.objects.count(), 1)
def test_empty_external_ref_always_mints(self):
payload = {"target_url": "https://mkdrealtor.com/x"}
first = self._post(payload)
second = self._post(payload)
self.assertEqual(first.status_code, 201)
self.assertEqual(second.status_code, 201)
self.assertNotEqual(_json(first)["code"], _json(second)["code"])
def test_code_uses_unambiguous_alphabet(self):
alphabet = set(settings.CODE_ALPHABET)
forbidden = set("01iloILO")
for _ in range(20):
response = self._post({"target_url": "https://mkdrealtor.com/x"})
code = _json(response)["code"]
self.assertTrue(set(code) <= alphabet)
self.assertFalse(set(code) & forbidden)
self.assertEqual(len(code), 6)
def test_stores_token_name_not_secret(self):
self._post({"target_url": "https://mkdrealtor.com/x"})
link = ShortLink.objects.get()
self.assertEqual(link.created_by_token, "monica")
self.assertNotIn("dev-only-token", link.created_by_token)
def test_disable_is_idempotent(self):
code = _json(self._post({"target_url": "https://mkdrealtor.com/x"}))["code"]
first = self.client.post(
f"/api/links/{code}/disable/",
HTTP_AUTHORIZATION=AUTH,
)
second = self.client.post(
f"/api/links/{code}/disable/",
HTTP_AUTHORIZATION=AUTH,
)
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertFalse(_json(second)["is_active"])
self.client.get(f"/{code}", HTTP_HOST="go.mkdrealtor.com")
# disabled → no redirect
response = self.client.get(f"/{code}", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
@override_settings(**SETTINGS)
class UseCaseTests(TestCase):
"""monica_site mints via Bearer; a phone hits the public short URL and 302s."""
def test_caller_creates_then_public_redirects(self):
create = self.client.post(
"/api/links/",
data=json.dumps(
{
"target_url": (
"https://mkdrealtor.com/listings/oak-st"
"?utm_source=monica&utm_medium=sms"
),
"title": "Oak St listing",
"external_ref": "campaign-uuid-optional",
}
),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer monica:dev-only-token",
HTTP_HOST="testserver",
)
self.assertEqual(create.status_code, 201)
body = _json(create)
self.assertEqual(
body["short_url"], f"https://go.mkdrealtor.com/{body['code']}"
)
follow = self.client.get(
f"/{body['code']}",
HTTP_HOST="go.mkdrealtor.com",
follow=False,
)
self.assertEqual(follow.status_code, 302)
self.assertEqual(
follow["Location"],
"https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms",
)
@override_settings(**SETTINGS)
class DebugCreateTests(TestCase):
def test_hidden_when_not_debug(self):
response = self.client.get("/debug/")
self.assertEqual(response.status_code, 404)
@override_settings(DEBUG=True)
def test_form_when_debug(self):
response = self.client.get("/debug/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Create a short link")
@override_settings(DEBUG=True)
def test_hidden_on_public_short_host_even_in_debug(self):
response = self.client.get("/debug/", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
@override_settings(DEBUG=True)
def test_post_mints_link(self):
response = self.client.post(
"/debug/",
{
"target_url": "https://mkdrealtor.com/listings/oak-st",
"title": "Oak St",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(ShortLink.objects.count(), 1)
link = ShortLink.objects.get()
self.assertEqual(link.created_by_token, "debug")
self.assertContains(response, link.public_short_url)
ADMIN_SETTINGS = {**SETTINGS, "SHORT_ADMIN_HOSTS": ["testserver", "localhost"]}
@override_settings(**ADMIN_SETTINGS)
class AdminTests(TestCase):
def setUp(self):
self.user = User.objects.create_superuser("admin", "admin@example.com", "pass")
self.client.force_login(self.user)
self.link = ShortLink.objects.create(
code="a3k9xm",
target_url="https://mkdrealtor.com/listings/oak-st",
title="Oak St",
created_by_token="monica",
)
def test_changelist(self):
response = self.client.get("/admin/links/shortlink/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "a3k9xm")
self.assertContains(response, "Oak St")
def test_add_page(self):
response = self.client.get("/admin/links/shortlink/add/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Target url")
def test_add_mints_code(self):
response = self.client.post(
"/admin/links/shortlink/add/",
{
"target_url": "https://mkdrealtor.com/new",
"title": "From admin",
"is_active": "on",
"external_ref": "",
"clicks-TOTAL_FORMS": "0",
"clicks-INITIAL_FORMS": "0",
"clicks-MIN_NUM_FORMS": "0",
"clicks-MAX_NUM_FORMS": "0",
"_save": "Save",
},
)
self.assertEqual(response.status_code, 302)
created = ShortLink.objects.exclude(code="a3k9xm").get()
self.assertEqual(created.target_url, "https://mkdrealtor.com/new")
self.assertEqual(created.created_by_token, "admin")
self.assertEqual(len(created.code), 6)
def test_disable_action(self):
response = self.client.post(
"/admin/links/shortlink/",
{
"action": "disable_links",
"_selected_action": [str(self.link.pk)],
},
)
self.assertEqual(response.status_code, 302)
self.link.refresh_from_db()
self.assertFalse(self.link.is_active)
def test_click_changelist(self):
Click.objects.create(link=self.link, user_agent="sms-client")
response = self.client.get("/admin/links/click/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "a3k9xm")
def test_admin_404_on_public_hosts(self):
self.client.logout()
response = self.client.get("/admin/", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
response = self.client.get("/admin/", HTTP_HOST="shortener.example.com")
self.assertEqual(response.status_code, 404)
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from links import api
urlpatterns = [
path("", api.links_collection, name="links-collection"),
path("<str:code>/", api.link_detail, name="links-detail"),
path("<str:code>/disable/", api.link_disable, name="links-disable"),
]
+31
View File
@@ -0,0 +1,31 @@
"""Public redirect — no auth. SMS recipients tap GET /<code>."""
from __future__ import annotations
import re
from django.http import Http404, HttpResponseRedirect
from django.views.decorators.http import require_http_methods
from links.models import ShortLink
from links.services import record_click_best_effort
CODE_RE = re.compile(r"^[a-z0-9]{4,8}$")
@require_http_methods(["GET", "HEAD"])
def redirect_view(request, code: str):
if not CODE_RE.fullmatch(code):
raise Http404()
try:
link = ShortLink.objects.get(code=code)
except ShortLink.DoesNotExist as exc:
raise Http404() from exc
if not link.is_available():
raise Http404()
if request.method == "GET":
record_click_best_effort(request, link)
return HttpResponseRedirect(link.target_url)
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main() -> None:
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shortener.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
+9
View File
@@ -0,0 +1,9 @@
"""ASGI config for shortener."""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shortener.settings")
application = get_asgi_application()
+12
View File
@@ -0,0 +1,12 @@
"""Load environment-specific Django settings based on DJANGO_ENV."""
import os
_environment = os.environ.get("DJANGO_ENV", "dev").lower()
if _environment == "prod":
from .prod import * # noqa: F403
elif _environment == "beta":
from .beta import * # noqa: F403
else:
from .dev import * # noqa: F403
+198
View File
@@ -0,0 +1,198 @@
"""Shared Django settings for all environments."""
import json
import os
from pathlib import Path
from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent.parent.parent
def env(key: str, default: str | None = None) -> str | None:
return os.environ.get(key, default)
def env_bool(key: str, default: bool = False) -> bool:
value = os.environ.get(key)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def env_list(key: str, default: str = "") -> list[str]:
value = os.environ.get(key, default)
if not value:
return []
value = value.strip()
if value.startswith("["):
try:
parsed = json.loads(value)
except ValueError:
parsed = None
if isinstance(parsed, list):
return [str(item).strip() for item in parsed if str(item).strip()]
return [item.strip() for item in value.split(",") if item.strip()]
def env_int(key: str, default: int) -> int:
value = env(key)
if value is None or value == "":
return default
return int(value)
def parse_api_tokens(raw: str) -> list[tuple[str, str]]:
"""Parse ``name:secret,name:secret`` into ``[(name, secret), ...]``."""
tokens: list[tuple[str, str]] = []
if not raw:
return tokens
for part in raw.split(","):
part = part.strip()
if not part or ":" not in part:
continue
name, secret = part.split(":", 1)
name, secret = name.strip(), secret.strip()
if name and secret:
tokens.append((name, secret))
return tokens
def database_config() -> dict:
database_url = env("DATABASE_URL")
if database_url:
parsed = urlparse(database_url)
return {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": parsed.path.lstrip("/"),
"USER": parsed.username or "",
"PASSWORD": parsed.password or "",
"HOST": parsed.hostname or "",
"PORT": str(parsed.port or 5432),
}
}
return {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
SECRET_KEY = env(
"DJANGO_SECRET_KEY",
"django-insecure-dev-only-change-me-before-production",
)
DEBUG = env_bool("DJANGO_DEBUG", False)
allowed_hosts = env_list(
"DJANGO_ALLOWED_HOSTS",
"localhost,127.0.0.1,0.0.0.0,testserver,web,url-shortener,go.mkdrealtor.com",
)
ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"]
INSTALLED_APPS = [
"core.apps.CoreConfig",
"links.apps.LinksConfig",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"links.middleware.HostSplitMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "shortener.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"core.context_processors.branding",
],
},
},
]
WSGI_APPLICATION = "shortener.wsgi.application"
DATABASES = database_config()
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "America/Chicago"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.memory.InMemoryStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# --- Shortener ---
SHORT_DOMAIN = env("SHORT_DOMAIN", "localhost:8000") or "localhost:8000"
PUBLIC_SHORT_URL = (env("PUBLIC_SHORT_URL", "https://go.mkdrealtor.com") or "").rstrip(
"/"
)
SHORT_PUBLIC_HOSTS = env_list("SHORT_PUBLIC_HOSTS", SHORT_DOMAIN.split(":")[0])
SHORT_API_HOSTS = env_list(
"SHORT_API_HOSTS",
"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.
SHORT_ADMIN_HOSTS = env_list("SHORT_ADMIN_HOSTS", "localhost,127.0.0.1")
SHORTENER_API_TOKENS = parse_api_tokens(env("SHORTENER_API_TOKENS", "") or "")
SHORT_ALLOWED_HOSTS = env_list(
"SHORT_ALLOWED_HOSTS", "mkdrealtor.com,aimloperations.com"
)
SHORT_CODE_LENGTH = env_int("SHORT_CODE_LENGTH", 6)
CLICK_IP_PEPPER = env("CLICK_IP_PEPPER", "") or ""
CODE_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz"
SITE_NAME = env("SITE_NAME", "URL Shortening Service") or "URL Shortening Service"
CREDIT_NAME = env("CREDIT_NAME", "AI ML Operations") or "AI ML Operations"
CREDIT_URL = env("CREDIT_URL", "https://aimloperations.com") or "https://aimloperations.com"
+18
View File
@@ -0,0 +1,18 @@
"""Beta/staging settings."""
from .base import * # noqa: F403
from .logging import build_logging_config, logging_level_for_env
DEBUG = env_bool("DJANGO_DEBUG", False) # noqa: F405
if DEBUG:
import warnings
warnings.warn("DEBUG is enabled in beta environment.", stacklevel=1)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
LOGGING = build_logging_config(logging_level_for_env("beta"), "beta")
+17
View File
@@ -0,0 +1,17 @@
"""Development settings."""
from .base import * # noqa: F403
from .logging import build_logging_config, logging_level_for_env
DEBUG = True
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.memory.InMemoryStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
LOGGING = build_logging_config(logging_level_for_env("dev"), "dev")
+82
View File
@@ -0,0 +1,82 @@
"""Environment-specific logging configuration."""
import os
def build_logging_config(level: str, environment: str) -> dict:
"""Return a Django LOGGING dict for the given level and environment name."""
return {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": (
f"{{levelname}} {{asctime}} {{name}} {{filename}}:{{lineno}} "
f"{{process:d}} {{thread:d}} [env={environment}] {{message}}"
),
"style": "{",
},
"simple": {
"format": (
f"{{levelname}} [env={environment}] "
f"{{filename}}:{{lineno}} {{message}}"
),
"style": "{",
},
},
"filters": {
"strip_authorization": {
"()": "shortener.settings.logging.StripAuthorizationFilter",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose" if environment == "dev" else "simple",
"filters": ["strip_authorization"],
},
},
"root": {
"handlers": ["console"],
"level": level,
},
"loggers": {
"django": {
"handlers": ["console"],
"level": level,
"propagate": False,
},
"django.request": {
"handlers": ["console"],
"level": "ERROR" if environment == "prod" else level,
"propagate": False,
},
"django.server": {
"handlers": ["console"],
"level": level,
"propagate": False,
},
},
}
class StripAuthorizationFilter:
"""Drop log records that appear to contain an Authorization header."""
def filter(self, record) -> bool:
message = record.getMessage()
if "authorization" in message.lower() and "bearer" in message.lower():
return False
return True
def logging_level_for_env(environment: str) -> str:
override = os.environ.get("DJANGO_LOG_LEVEL")
if override:
return override.upper()
if environment == "dev":
return "DEBUG"
if environment == "beta":
return "INFO"
return "WARNING"
+16
View File
@@ -0,0 +1,16 @@
"""Production settings."""
from .base import * # noqa: F403
from .logging import build_logging_config, logging_level_for_env
DEBUG = False
if not env("DJANGO_SECRET_KEY"): # noqa: F405
raise ValueError("DJANGO_SECRET_KEY must be set in production.")
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
LOGGING = build_logging_config(logging_level_for_env("prod"), "prod")
+17
View File
@@ -0,0 +1,17 @@
"""URL configuration for shortener."""
from django.contrib import admin
from django.urls import include, path
from core.views import healthz, landing
from links.debug_views import debug_create
from links.views import redirect_view
urlpatterns = [
path("", landing, name="landing"),
path("healthz/", healthz, name="healthz"),
path("debug/", debug_create, name="debug-create"),
path("admin/", admin.site.urls),
path("api/links/", include("links.urls")),
path("<str:code>", redirect_view, name="redirect"),
]
+9
View File
@@ -0,0 +1,9 @@
"""WSGI config for shortener."""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shortener.settings")
application = get_wsgi_application()
Generated
+148
View File
@@ -0,0 +1,148 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "asgiref"
version = "3.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" },
]
[[package]]
name = "django"
version = "6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e2/42/6cb20996733984c1f6661daeda3877990836c76c633c6c8879d39f7120eb/django-6.1.tar.gz", hash = "sha256:86a2aacd59b817e4d6ac2ebfe22356c58f66f7b24e503f71b7c2fead677ee48b", size = 11223034, upload-time = "2026-08-05T19:21:53.789Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/9c/ce847620134cfab903e75690c498af73b46abbede2912ea89bd76d5c1e76/django-6.1-py3-none-any.whl", hash = "sha256:6c132cd980c9392b06807d4ca52d72530d631dc65a85d9dacede00a780cefbbe", size = 8417399, upload-time = "2026-08-05T19:21:47.285Z" },
]
[[package]]
name = "gunicorn"
version = "26.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" },
]
[[package]]
name = "psycopg"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" },
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" },
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" },
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" },
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" },
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" },
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" },
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" },
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" },
{ url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" },
{ url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" },
{ url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" },
{ url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" },
{ url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" },
{ url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" },
{ url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" },
{ url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" },
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
{ url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
{ url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
{ url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
{ url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
]
[[package]]
name = "sqlparse"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "tzdata"
version = "2026.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
]
[[package]]
name = "url-shortening-service"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "gunicorn" },
{ name = "psycopg", extra = ["binary"] },
{ name = "whitenoise" },
]
[package.metadata]
requires-dist = [
{ name = "django", specifier = ">=6.0,<7" },
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" },
{ name = "whitenoise", specifier = ">=6.9.0" },
]
[[package]]
name = "whitenoise"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
]