diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8cb9876 --- /dev/null +++ b/.env.example @@ -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 / (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 diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..a1c0980 --- /dev/null +++ b/.env.prod.example @@ -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: +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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..6dc17f0 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitea/workflows/deploy-beta.yml b/.gitea/workflows/deploy-beta.yml new file mode 100644 index 0000000..ae12964 --- /dev/null +++ b/.gitea/workflows/deploy-beta.yml @@ -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 }}" diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml new file mode 100644 index 0000000..a2f8583 --- /dev/null +++ b/.gitea/workflows/deploy-prod.yml @@ -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 }}" diff --git a/.gitignore b/.gitignore index 36b13f1..51685c3 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/API.md b/API.md new file mode 100644 index 0000000..eb34a5b --- /dev/null +++ b/API.md @@ -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 /` (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 /` | + +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:,scha: + ``` + + 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 caller’s 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 app’s env (not this repo): + +```text +# Prod +SHORTENER_BASE_URL=https://shortener.aimloperations.com +SHORTENER_API_TOKEN=monica: + +# Beta (when that host exists) +# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com +# SHORTENER_API_TOKEN=monica: + +# 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: +``` + +That value must match an entry in this service’s `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 : +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: +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 /` 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 service’s `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//` + +One link, including `click_count`. **404** if the code does not exist. + +--- + +## 6. `POST /api/links//disable/` + +Sets `is_active=false`. Idempotent. **200** with the updated row. + +After disable, public `GET /` is **404** (no redirect). + +--- + +## 7. What the public does (not the API) + +`GET https:///` — 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/`. + +```bash +curl -sSI "${SHORTENER_BASE_URL}/" +``` + +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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..42e0d06 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Implementation.md b/Implementation.md new file mode 100644 index 0000000..72f8068 --- /dev/null +++ b/Implementation.md @@ -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 /` so SMS recipients can tap the link with no token. +- Create / list / disable **only** via a Bearer-authenticated API (own hostname; may be public DNS). +- Named, rotatable tokens so `monica_site` can be revoked without rotating every caller. +- Target-host allowlist so a stolen token cannot mint open redirects off the short domain. +- Click counts for campaign reporting. +- Same deploy shape as `monica_site`: `uv`, Docker, Postgres in prod, Gitea CI, `server-infra`. + +### Non-goals (v1) + +- Public web UI or Django admin exposed on the short domain. +- Query-string tokens (`?token=`). Header only. +- Per-recipient unique codes (who clicked). Add later if needed. +- Custom vanity slugs (`/openhouse`). Random codes only in v1. +- Multi-tenant SaaS, rate-limit dashboards, QR generation. + +--- + +## 2. Security model (do this first) + +Two surfaces. Mixing them is the failure mode. + +| Surface | Who | Auth | Network | +|---------|-----|------|---------| +| `GET /` | anyone with the SMS | **none** | public short hostname (NPM + TLS) | +| `/api/links/` (all methods) | `monica_site` and other trusted callers | **Bearer** | own hostname — **may be public DNS**. Token is the lock. | + +### 2.1 Public redirect + +SMS recipients have no token. Do **not** put Bearer on `GET /`. That path is the +product. + +Use **302**, not 301. 301 is cached forever and later click counts (and disable) stop +working. + +### 2.2 Authenticated API + +- `Authorization: Bearer ` on every `/api/` request. +- If `SHORTENER_API_TOKENS` is empty, API returns **503**. Fail closed. +- Constant-time compare (`hmac.compare_digest`) against every configured token. +- Named tokens: `monica:`, `admin:`. Store the name on the `ShortLink` + row as `created_by_token`. +- Generate secrets with `python -c "import secrets; print(secrets.token_urlsafe(32))"`. +- Never put the token in the short URL, logs, or git. Do not reuse + `DJANGO_SECRET_KEY` or any `monica_site` webhook secret. +- Do not log the `Authorization` header (Gunicorn/NPM access logs). Strip or disable it. + +### 2.3 Two public hostnames (Bearer is the lock on /api/) + +`/api/links/` may have its own public DNS record. Anyone who finds that URL still +cannot mint links without a configured Bearer token. Empty `SHORTENER_API_TOKENS` +→ **503**. Missing/wrong token → **401**. Do not add CORS `*` (server-to-server only). + +``` +Internet + │ + ├─ NPM: SHORT_DOMAIN (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 / 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 | 4–6 chars from alphabet below | +| `target_url` | `URLField(2048)` | stored canonical https URL | +| `title` | `CharField(200)`, blank | caller-supplied note | +| `created_by_token` | `CharField(64)` | token **name** (`monica`), never the secret | +| `external_ref` | `CharField(64)`, blank, indexed | optional caller id (campaign UUID) | +| `is_active` | bool, default True | | +| `expires_at` | datetime, nullable | | +| `click_count` | positive int, default 0 | denormalized | +| `created_at` / `updated_at` | timestamps | | + +Disable without deleting. Inactive or expired codes 404 (no redirect). Do not retarget an +existing code to a new URL in v1 (prevents swapping a live SMS onto a new destination). + +### `Click` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | UUID PK | | +| `link` | FK `ShortLink` | | +| `occurred_at` | datetime | | +| `ip_hash` | `CharField(64)`, blank | HMAC-SHA256 of remote IP with a server pepper; never store raw IP | +| `user_agent` | `CharField(512)`, blank | truncated | +| `referrer` | `CharField(1024)`, blank | | + +Increment `ShortLink.click_count` in the same request as inserting `Click` (or +`F('click_count') + 1`). Do not block the 302 on analytics failure: log and still redirect +if the click insert fails. + +### Code alphabet + +``` +23456789abcdefghjkmnpqrstuvwxyz +``` + +No `0/O`, `1/l/I`. Length 6 by default (`SHORT_CODE_LENGTH=6`). Collision: retry a small +number of times, then 500. + +Do not accept caller-supplied codes in v1. + +--- + +## 7. HTTP API (internal host only) + +All `/api/` routes require Bearer. JSON in/out. CSRF exempt (token auth, no cookies). + +Prefix: `/api/links/` + +### `POST /api/links/` + +Create. + +Request: + +```json +{ + "target_url": "https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms", + "title": "Oak St listing", + "external_ref": "campaign-uuid-optional", + "expires_at": null +} +``` + +Response `201`: + +```json +{ + "code": "a3k9xm", + "short_url": "https://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//` + +Detail including `click_count`. + +### `POST /api/links//disable/` + +Set `is_active=false`. Idempotent. `200`. + +No DELETE in v1. No PATCH of `target_url`. + +### Auth helper + +```python +# links/auth.py +# Parse Authorization: Bearer +# Split named tokens from SHORTENER_API_TOKENS (comma-separated name:secret) +# compare_digest each secret +# Return token name or None +``` + +Decorator / mixin on all API views. Wrong or missing → `401` with +`WWW-Authenticate: Bearer`. Do not distinguish "unknown token" vs "malformed" in the body. + +--- + +## 8. Public redirect + +`GET /` on the public host. + +1. Lookup code (404 if missing, inactive, or expired). +2. Insert `Click`, bump `click_count` (best-effort). +3. `HttpResponseRedirect(target_url)` — **302**. + +Reject codes that do not match `^[a-z0-9]{4,8}$` with 404 (no extra work). + +`GET /` on the public host: landing page (“URL shortening service”, credit +`aimloperations.com`). Does not advertise the API. + +`GET /debug/`: mint form, **DEBUG only**, never on `SHORT_PUBLIC_HOSTS`. + +`GET /healthz/`: `{"status": "ok"}`. + +--- + +## 9. Environment + +### This service + +| Var | Purpose | +|-----|---------| +| `DJANGO_ENV` | `dev` / `beta` / `prod` | +| `DJANGO_SECRET_KEY` | Django signing; **not** an API token | +| `DJANGO_DEBUG` | false in prod | +| `DJANGO_ALLOWED_HOSTS` | public short host **and** API hostname | +| `DATABASE_URL` | Postgres; empty → SQLite | +| `SHORT_DOMAIN` | public hostname phones use (`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: +``` + +`monica_site` sends `Authorization: Bearer monica:` to +`POST {SHORTENER_BASE_URL}/api/links/` and substitutes `short_url` into SMS bodies +**after** UTM is attached. + +Do not implement the caller in this repo. Document the contract only. + +--- + +## 10. Docker + +### `Dockerfile` + +Same pattern as `monica_site`: + +- `FROM python:3.12-slim` +- install `libpq5`, copy `uv` +- `uv sync --frozen --no-dev` +- copy `site/` + entrypoint +- `EXPOSE 8000` + +### `docker-compose.yml` (dev) + +- `db`: Postgres 16 +- `web`: build `.`, mount `./site`, `DJANGO_ENV=dev` → runserver +- **Do not** publish the host port to the LAN unless you are testing redirects. + Prefer `127.0.0.1:8005:8000` (container still listens on 8000). +- Internal API hostname: service name `web` (or `url-shortener`). + +### `docker-compose.prod.yml` + +- `web` only. External Postgres via `DATABASE_URL`. +- `ports: "${WEB_PORT:-8005}:8000"` (pick a free host port; document it). +- `env_file: .env` +- No worker profile. + +### Entrypoint + +Wait for DB → `migrate --noinput` → if `DJANGO_ENV=dev` then `runserver`, else +`collectstatic` + gunicorn. + +--- + +## 11. NPM / deploy notes (ops, not code) + +When the domains exist: + +1. Public NPM proxy host = `SHORT_DOMAIN` → this container’s `WEB_PORT`. +2. Proxy `/` (landing) and the code regex. `/api/`, `/admin/`, `/debug/` → 404. +3. TLS like other apps. +4. Add `SHORT_DOMAIN` to `DJANGO_ALLOWED_HOSTS` and `SHORT_PUBLIC_HOSTS`. +5. `PUBLIC_SHORT_URL=https://`. +6. Second NPM proxy host = API hostname → same container. Proxy `/api/` only. +7. Add the API hostname to `DJANGO_ALLOWED_HOSTS` and `SHORT_API_HOSTS`. + +CI (same split as `monica_site`, default branch `master`): + +- `.gitea/workflows/ci.yml` — unit tests on pull requests +- `.gitea/workflows/deploy-beta.yml` — on push to `master`: unit tests → compose + tests → `server-infra/scripts/deploy.sh --app url_shortening_service --env beta` +- `.gitea/workflows/deploy-prod.yml` — `workflow_dispatch` (Actions button): same + tests, then `--env prod` + +--- + +## 12. Tests (required before merge) + +Django `TestCase` / `SimpleTestCase`. No live network. + +- Auth: missing Bearer → 401; wrong token → 401; empty `SHORTENER_API_TOKENS` → 503; + matching named token → 201. +- Host split: request to short host `/api/links/` → 404 even with valid Bearer. + Public API host without Bearer → 401; valid Bearer → 201. `/admin/` 404 on API host. +- Allowlist: `https://mkdrealtor.com/x` ok; `http://…` 400; `https://evil.com` 400; + `javascript:alert(1)` 400. +- Redirect: active code → 302 to target; inactive/expired/unknown → 404. +- Redirect is 302, not 301. +- Click row created and `click_count` incremented. +- Idempotent create with same `target_url` + `external_ref`. +- `short_url` uses `PUBLIC_SHORT_URL`, not the API Host header. +- Code charset: generated codes only use the unambiguous alphabet. + +--- + +## 13. Implementation order + +1. `uv init` / `pyproject.toml` + Django project + settings/env helpers. +2. `links` models + migration. +3. `links/auth.py` + API views (create/list/detail/disable). +4. Redirect view + host-split middleware. +5. Dockerfile, compose, entrypoint, `.env.example`. +6. Tests in §12. +7. README: how to run locally, how a caller authenticates, what is public vs internal. +8. Stop. Caller integration lives in `monica_site` after this service is up. + +Phase 2 (not this pass): SMS auto-shorten in `monica_site` composer, custom slugs, +per-recipient codes, internal-only admin UI. + +--- + +## 14. Local run (target) + +```bash +cp .env.example .env +# set SHORTENER_API_TOKENS=monica:dev-only-token +docker compose up --build +# or: +uv sync +cd site && uv run python manage.py migrate +uv run python manage.py runserver +``` + +Mint (API host / localhost): + +```bash +curl -sS -X POST http://127.0.0.1:8005/api/links/ \ + -H "Authorization: Bearer monica:dev-only-token" \ + -H "Content-Type: application/json" \ + -d '{"target_url":"https://mkdrealtor.com/","title":"test"}' +``` + +Follow (public path): + +```bash +curl -sSI http://127.0.0.1:8005/ +``` diff --git a/README.md b/README.md index 21d8738..027e259 100644 --- a/README.md +++ b/README.md @@ -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 /` 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 /` | 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 /` 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/ +``` + +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: +``` + +```http +POST /api/links/ +Authorization: Bearer monica: +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`. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..5979b58 --- /dev/null +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e567448 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1c2b790 --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..0b58a62 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -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}" diff --git a/site/core/__init__.py b/site/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/site/core/__init__.py @@ -0,0 +1 @@ + diff --git a/site/core/apps.py b/site/core/apps.py new file mode 100644 index 0000000..c0ce093 --- /dev/null +++ b/site/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core" diff --git a/site/core/context_processors.py b/site/core/context_processors.py new file mode 100644 index 0000000..f67f41f --- /dev/null +++ b/site/core/context_processors.py @@ -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()), + } diff --git a/site/core/templates/core/base.html b/site/core/templates/core/base.html new file mode 100644 index 0000000..9cc3a09 --- /dev/null +++ b/site/core/templates/core/base.html @@ -0,0 +1,128 @@ + + + + + + {% block title %}{{ SITE_NAME }}{% endblock %} + + + + {% if debug %} + + {% endif %} +
+ {% block content %}{% endblock %} + +
+ + diff --git a/site/core/templates/core/landing.html b/site/core/templates/core/landing.html new file mode 100644 index 0000000..e83a09d --- /dev/null +++ b/site/core/templates/core/landing.html @@ -0,0 +1,14 @@ +{% extends "core/base.html" %} +{% block title %}{{ SITE_NAME }}{% endblock %} +{% block content %} +

{{ SITE_NAME }}

+

{{ SHORT_DOMAIN }}

+

+ This is a URL shortening service. A path like + /a3k9xm sends you to the long HTTPS URL. +

+

+ Created by {{ CREDIT_NAME }} + · aimloperations.com +

+{% endblock %} diff --git a/site/core/views.py b/site/core/views.py new file mode 100644 index 0000000..aa63024 --- /dev/null +++ b/site/core/views.py @@ -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") diff --git a/site/links/__init__.py b/site/links/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/site/links/__init__.py @@ -0,0 +1 @@ + diff --git a/site/links/admin.py b/site/links/admin.py new file mode 100644 index 0000000..f654ec5 --- /dev/null +++ b/site/links/admin.py @@ -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('{0}', 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 "—") diff --git a/site/links/api.py b/site/links/api.py new file mode 100644 index 0000000..cc3937d --- /dev/null +++ b/site/links/api.py @@ -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)) diff --git a/site/links/apps.py b/site/links/apps.py new file mode 100644 index 0000000..964efc6 --- /dev/null +++ b/site/links/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LinksConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "links" diff --git a/site/links/auth.py b/site/links/auth.py new file mode 100644 index 0000000..61de518 --- /dev/null +++ b/site/links/auth.py @@ -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 diff --git a/site/links/debug_views.py b/site/links/debug_views.py new file mode 100644 index 0000000..bb30962 --- /dev/null +++ b/site/links/debug_views.py @@ -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}, + ) diff --git a/site/links/forms.py b/site/links/forms.py new file mode 100644 index 0000000..bf115ac --- /dev/null +++ b/site/links/forms.py @@ -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 diff --git a/site/links/middleware.py b/site/links/middleware.py new file mode 100644 index 0000000..7e91eda --- /dev/null +++ b/site/links/middleware.py @@ -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) diff --git a/site/links/migrations/0001_initial.py b/site/links/migrations/0001_initial.py new file mode 100644 index 0000000..d8681a4 --- /dev/null +++ b/site/links/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/site/links/migrations/__init__.py b/site/links/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/links/models.py b/site/links/models.py new file mode 100644 index 0000000..2b3789c --- /dev/null +++ b/site/links/models.py @@ -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}" diff --git a/site/links/services.py b/site/links/services.py new file mode 100644 index 0000000..e19523f --- /dev/null +++ b/site/links/services.py @@ -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) diff --git a/site/links/templates/links/debug_create.html b/site/links/templates/links/debug_create.html new file mode 100644 index 0000000..be171ba --- /dev/null +++ b/site/links/templates/links/debug_create.html @@ -0,0 +1,35 @@ +{% extends "core/base.html" %} +{% block title %}Create short link · debug{% endblock %} +{% block content %} +

Debug only

+

Create a short link

+

Uses the same allowlist as the API. Not served when DEBUG is false.

+ + {% if messages %} +
    + {% for message in messages %}
  • {{ message }}
  • {% endfor %} +
+ {% endif %} + + {% if created_link %} +
+

Short URL

+

{{ created_link.public_short_url }}

+

Target: {{ created_link.target_url }}

+ Open +
+ {% endif %} + +
+ {% csrf_token %} + {{ form.non_field_errors }} + {% for field in form %} + + {% endfor %} + +
+{% endblock %} diff --git a/site/links/tests.py b/site/links/tests.py new file mode 100644 index 0000000..fcf5d25 --- /dev/null +++ b/site/links/tests.py @@ -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) diff --git a/site/links/urls.py b/site/links/urls.py new file mode 100644 index 0000000..8a0f077 --- /dev/null +++ b/site/links/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from links import api + +urlpatterns = [ + path("", api.links_collection, name="links-collection"), + path("/", api.link_detail, name="links-detail"), + path("/disable/", api.link_disable, name="links-disable"), +] diff --git a/site/links/views.py b/site/links/views.py new file mode 100644 index 0000000..5f1d4fd --- /dev/null +++ b/site/links/views.py @@ -0,0 +1,31 @@ +"""Public redirect — no auth. SMS recipients tap GET /.""" + +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) diff --git a/site/manage.py b/site/manage.py new file mode 100644 index 0000000..b8203f6 --- /dev/null +++ b/site/manage.py @@ -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() diff --git a/site/shortener/__init__.py b/site/shortener/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/site/shortener/__init__.py @@ -0,0 +1 @@ + diff --git a/site/shortener/asgi.py b/site/shortener/asgi.py new file mode 100644 index 0000000..f18a82e --- /dev/null +++ b/site/shortener/asgi.py @@ -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() diff --git a/site/shortener/settings/__init__.py b/site/shortener/settings/__init__.py new file mode 100644 index 0000000..fc73b65 --- /dev/null +++ b/site/shortener/settings/__init__.py @@ -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 diff --git a/site/shortener/settings/base.py b/site/shortener/settings/base.py new file mode 100644 index 0000000..793c34a --- /dev/null +++ b/site/shortener/settings/base.py @@ -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" diff --git a/site/shortener/settings/beta.py b/site/shortener/settings/beta.py new file mode 100644 index 0000000..840c157 --- /dev/null +++ b/site/shortener/settings/beta.py @@ -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") diff --git a/site/shortener/settings/dev.py b/site/shortener/settings/dev.py new file mode 100644 index 0000000..f945692 --- /dev/null +++ b/site/shortener/settings/dev.py @@ -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") diff --git a/site/shortener/settings/logging.py b/site/shortener/settings/logging.py new file mode 100644 index 0000000..1df67a1 --- /dev/null +++ b/site/shortener/settings/logging.py @@ -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" diff --git a/site/shortener/settings/prod.py b/site/shortener/settings/prod.py new file mode 100644 index 0000000..9715dbd --- /dev/null +++ b/site/shortener/settings/prod.py @@ -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") diff --git a/site/shortener/urls.py b/site/shortener/urls.py new file mode 100644 index 0000000..c221eb9 --- /dev/null +++ b/site/shortener/urls.py @@ -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("", redirect_view, name="redirect"), +] diff --git a/site/shortener/wsgi.py b/site/shortener/wsgi.py new file mode 100644 index 0000000..43cbe1a --- /dev/null +++ b/site/shortener/wsgi.py @@ -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() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1fbeacb --- /dev/null +++ b/uv.lock @@ -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" }, +]