Add v1 shortener: Bearer API, public 302, landing, and CI.
CI / test (pull_request) Successful in 6s
CI / test (pull_request) Successful in 6s
Standalone Django service so callers can mint links and phones get a 302. Closes #1.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# Local development defaults. Copy to `.env` (gitignored) — never commit secrets.
|
||||
# docker compose auto-loads `.env` for ${VAR} substitution into the web container.
|
||||
|
||||
DJANGO_ENV=dev
|
||||
DJANGO_DEBUG=true
|
||||
DJANGO_SECRET_KEY=dev-only-change-me
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,web,url-shortener
|
||||
|
||||
# Leave empty for SQLite when running manage.py on the host.
|
||||
# Compose ignores this and uses the bundled Postgres via COMPOSE_DATABASE_URL.
|
||||
# DATABASE_URL=
|
||||
|
||||
SITE_NAME=URL Shortening Service
|
||||
CREDIT_NAME=AI ML Operations
|
||||
CREDIT_URL=https://aimloperations.com
|
||||
|
||||
SHORT_DOMAIN=localhost:8005
|
||||
# Origin printed in minted short_url (phones hit this). Local: this machine.
|
||||
PUBLIC_SHORT_URL=http://127.0.0.1:8005
|
||||
# Host values that only serve GET /<code> (no /api/).
|
||||
SHORT_PUBLIC_HOSTS=go.mkdrealtor.com
|
||||
# Host values that serve /api/ (Bearer required). May include a public DNS name.
|
||||
SHORT_API_HOSTS=localhost,127.0.0.1,0.0.0.0,web,url-shortener
|
||||
# Django admin — keep local. Do not add the public API hostname.
|
||||
SHORT_ADMIN_HOSTS=localhost,127.0.0.1
|
||||
# Named, rotatable tokens. This is what keeps /api/ closed on a public hostname.
|
||||
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# Format: name:secret,name:secret — never reuse DJANGO_SECRET_KEY.
|
||||
SHORTENER_API_TOKENS=monica:dev-only-token
|
||||
# target_url hostname allowlist (exact or suffix).
|
||||
SHORT_ALLOWED_HOSTS=mkdrealtor.com,aimloperations.com,*.aimloperations.com
|
||||
SHORT_CODE_LENGTH=6
|
||||
# HMAC pepper for click IP hashes. Distinct from DJANGO_SECRET_KEY.
|
||||
CLICK_IP_PEPPER=dev-click-pepper-change-me
|
||||
|
||||
GUNICORN_WORKERS=2
|
||||
@@ -0,0 +1,54 @@
|
||||
# Secret env files for server-infra deploy.
|
||||
# Copy to the control node (never commit):
|
||||
# ~/Documents/secrets/url_shortening_service/url_shortening_service_prod.env
|
||||
# ~/Documents/secrets/url_shortening_service/url_shortening_service_beta.env
|
||||
#
|
||||
# Docker Compose: if a secret contains $ (e.g. in DATABASE_URL password), escape each
|
||||
# $ as $$ or compose will treat $word as a variable.
|
||||
|
||||
# =============================================================================
|
||||
# PROD
|
||||
# =============================================================================
|
||||
DJANGO_ENV=prod
|
||||
DJANGO_DEBUG=false
|
||||
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
||||
# Public short host AND public API hostname (plus docker names if used).
|
||||
DJANGO_ALLOWED_HOSTS=go.mkdrealtor.com,shortener.aimloperations.com,url-shortener,web
|
||||
|
||||
# Shared external Postgres
|
||||
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/url_shortener
|
||||
|
||||
# Host port (must match server-infra). NPM proxies SHORT_DOMAIN here.
|
||||
WEB_PORT=8005
|
||||
|
||||
SHORT_DOMAIN=go.mkdrealtor.com
|
||||
PUBLIC_SHORT_URL=https://go.mkdrealtor.com
|
||||
SHORT_PUBLIC_HOSTS=go.mkdrealtor.com
|
||||
SHORT_API_HOSTS=shortener.aimloperations.com,url-shortener,web
|
||||
SHORT_ADMIN_HOSTS=localhost,127.0.0.1
|
||||
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# This token is the only thing that authorizes minting. Treat it as a secret.
|
||||
# monica_site sends: Authorization: Bearer monica:<same-secret>
|
||||
SHORTENER_API_TOKENS=monica:replace-with-token-urlsafe-32
|
||||
SHORT_ALLOWED_HOSTS=mkdrealtor.com
|
||||
SHORT_CODE_LENGTH=6
|
||||
CLICK_IP_PEPPER=replace-with-a-distinct-pepper
|
||||
|
||||
GUNICORN_WORKERS=2
|
||||
GUNICORN_BIND=0.0.0.0:8000
|
||||
|
||||
# =============================================================================
|
||||
# BETA overrides
|
||||
# File: url_shortening_service_beta.env
|
||||
# =============================================================================
|
||||
# DJANGO_ENV=beta
|
||||
# DJANGO_DEBUG=false
|
||||
# DJANGO_SECRET_KEY=replace-with-a-different-beta-secret
|
||||
# DJANGO_ALLOWED_HOSTS=go-beta.example.com,shortener-beta.aimloperations.com,url-shortener,web
|
||||
# DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/url_shortener_beta
|
||||
# WEB_PORT=8015
|
||||
# SHORT_DOMAIN=go-beta.example.com
|
||||
# PUBLIC_SHORT_URL=https://go-beta.example.com
|
||||
# SHORT_PUBLIC_HOSTS=go-beta.example.com
|
||||
# SHORTENER_API_TOKENS=monica:replace-with-a-different-token
|
||||
# CLICK_IP_PEPPER=replace-with-a-different-pepper
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Deploy Prod
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run unit tests
|
||||
env:
|
||||
DJANGO_ENV: dev
|
||||
DJANGO_SECRET_KEY: test-secret-key
|
||||
DATABASE_URL: ""
|
||||
DB_HOST: ""
|
||||
SHORTENER_API_TOKENS: monica:dev-only-token
|
||||
PUBLIC_SHORT_URL: https://go.mkdrealtor.com
|
||||
SHORT_PUBLIC_HOSTS: go.mkdrealtor.com
|
||||
SHORT_API_HOSTS: testserver,localhost,127.0.0.1
|
||||
SHORT_ALLOWED_HOSTS: mkdrealtor.com
|
||||
CLICK_IP_PEPPER: test-pepper
|
||||
run: |
|
||||
cd site
|
||||
uv run python manage.py test
|
||||
|
||||
docker:
|
||||
needs: unit-tests
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker compose build
|
||||
|
||||
- name: Run containerized tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
unset DATABASE_URL DB_HOST DB_NAME DB_USER DB_PASSWORD DB_PORT \
|
||||
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
|
||||
DJANGO_ALLOWED_HOSTS || true
|
||||
|
||||
PROJECT="shortener-ci-${{ gitea.sha }}"
|
||||
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
docker compose -p "$PROJECT" up -d --wait db
|
||||
docker compose -p "$PROJECT" run --rm --no-deps --entrypoint "" \
|
||||
-e DJANGO_ENV=dev \
|
||||
-e DJANGO_SECRET_KEY=test-secret-key \
|
||||
-e DJANGO_DEBUG=true \
|
||||
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
|
||||
-e DATABASE_URL=postgres://url_shortener:url_shortener@db:5432/url_shortener \
|
||||
-e SHORTENER_API_TOKENS=monica:dev-only-token \
|
||||
-e PUBLIC_SHORT_URL=https://go.mkdrealtor.com \
|
||||
-e SHORT_PUBLIC_HOSTS=go.mkdrealtor.com \
|
||||
-e SHORT_API_HOSTS=testserver,localhost,127.0.0.1 \
|
||||
-e SHORT_ALLOWED_HOSTS=mkdrealtor.com \
|
||||
-e CLICK_IP_PEPPER=test-pepper \
|
||||
web uv run python manage.py test
|
||||
|
||||
deploy-prod:
|
||||
needs: docker
|
||||
runs-on: self-hosted
|
||||
env:
|
||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
|
||||
steps:
|
||||
- name: Deploy url_shortening_service prod to all webservers
|
||||
run: |
|
||||
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
|
||||
--app url_shortening_service \
|
||||
--env prod \
|
||||
--ref "${{ gitea.sha }}"
|
||||
+2
-2
@@ -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/
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
# URL shortener — caller API
|
||||
|
||||
For other services (`monica_site`, later callers) that mint short links.
|
||||
|
||||
This service has two public hostnames. **Call the API host.** Never create links
|
||||
on the short domain (`aiml.pw` / `cidinn.li` / `go.mkdrealtor.com`). That host
|
||||
only serves `GET /` (landing) and `GET /<code>` (302). `/api/` there is **404**.
|
||||
|
||||
| Host | Example | What you call |
|
||||
|------|---------|----------------|
|
||||
| API | `https://shortener.aimloperations.com` | `POST/GET /api/links/` |
|
||||
| Short | `https://aiml.pw` (or `cidinn.li`) | phones only — `GET /<code>` |
|
||||
|
||||
Local compose: API + redirects on `http://127.0.0.1:8005`.
|
||||
|
||||
JSON in/out. CSRF-exempt. **Server-to-server only** — no CORS `*`. Do not call
|
||||
this from a browser.
|
||||
|
||||
---
|
||||
|
||||
## 1. Onboard a new caller
|
||||
|
||||
Two sides. A named token is the only lock. Anyone who has the URL still cannot
|
||||
mint without it.
|
||||
|
||||
### 1a. This service (operator)
|
||||
|
||||
1. Generate a secret (do **not** reuse `DJANGO_SECRET_KEY` or a webhook secret):
|
||||
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
```
|
||||
|
||||
2. Pick a short token **name** for the caller (`monica`, `scha`, `chat`, …).
|
||||
Revoking one name does not rotate the others.
|
||||
|
||||
3. Append `name:secret` to `SHORTENER_API_TOKENS` (comma-separated). Redeploy
|
||||
or restart so settings reload.
|
||||
|
||||
```text
|
||||
SHORTENER_API_TOKENS=monica:<secret>,scha:<other-secret>
|
||||
```
|
||||
|
||||
Prod/beta files:
|
||||
|
||||
```text
|
||||
~/Documents/secrets/url_shortening_service/url_shortening_service_prod.env
|
||||
~/Documents/secrets/url_shortening_service/url_shortening_service_beta.env
|
||||
```
|
||||
|
||||
4. If the 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:<same-secret>
|
||||
|
||||
# Beta (when that host exists)
|
||||
# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com
|
||||
# SHORTENER_API_TOKEN=monica:<beta-secret>
|
||||
|
||||
# Local (this service via compose)
|
||||
# SHORTENER_BASE_URL=http://127.0.0.1:8005
|
||||
# SHORTENER_API_TOKEN=monica:dev-only-token
|
||||
```
|
||||
|
||||
Send:
|
||||
|
||||
```http
|
||||
Authorization: Bearer monica:<secret>
|
||||
```
|
||||
|
||||
That value must match an entry in this 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 <name>:<secret>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
| Situation | Status | Body |
|
||||
|-----------|--------|------|
|
||||
| Missing or wrong Bearer | **401** | `{"detail":"Unauthorized"}` + `WWW-Authenticate: Bearer` |
|
||||
| No tokens configured on the server | **503** | `{"detail":"Service unavailable"}` |
|
||||
| Valid token | continues | — |
|
||||
|
||||
401 does not distinguish “unknown token” vs “malformed header”.
|
||||
|
||||
---
|
||||
|
||||
## 3. `POST /api/links/`
|
||||
|
||||
Mint a short link.
|
||||
|
||||
### Request
|
||||
|
||||
```http
|
||||
POST /api/links/
|
||||
Authorization: Bearer monica:<secret>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"target_url": "https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms",
|
||||
"title": "Oak St listing",
|
||||
"external_ref": "campaign-uuid-optional",
|
||||
"expires_at": null
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|--------|
|
||||
| `target_url` | yes | `https` only. Host must match `SHORT_ALLOWED_HOSTS`. No `http`, `javascript:`, `data:`, `//evil.com`, or `user:pass@host`. |
|
||||
| `title` | no | Note, max 200 chars. |
|
||||
| `external_ref` | no | Your id (campaign UUID), max 64. Empty → always mint a **new** code. |
|
||||
| `expires_at` | no | ISO-8601 datetime or `null`. After this, `GET /<code>` is 404. |
|
||||
|
||||
### Response `201` (new)
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "a3k9xm",
|
||||
"short_url": "https://aiml.pw/a3k9xm",
|
||||
"target_url": "https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms",
|
||||
"title": "Oak St listing",
|
||||
"is_active": true,
|
||||
"click_count": 0,
|
||||
"created_at": "2026-08-30T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
`short_url` is built from this 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/<code>/`
|
||||
|
||||
One link, including `click_count`. **404** if the code does not exist.
|
||||
|
||||
---
|
||||
|
||||
## 6. `POST /api/links/<code>/disable/`
|
||||
|
||||
Sets `is_active=false`. Idempotent. **200** with the updated row.
|
||||
|
||||
After disable, public `GET /<code>` is **404** (no redirect).
|
||||
|
||||
---
|
||||
|
||||
## 7. What the public does (not the API)
|
||||
|
||||
`GET https://<SHORT_DOMAIN>/<code>` — no token.
|
||||
|
||||
- Active + unexpired → **302** to `target_url` (not 301). Click counted.
|
||||
- Missing / disabled / expired / bad shape → **404**.
|
||||
- `GET /` → landing page.
|
||||
|
||||
Do not put the Bearer token on this URL.
|
||||
|
||||
---
|
||||
|
||||
## 8. Local smoke
|
||||
|
||||
This service:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # SHORTENER_API_TOKENS=monica:dev-only-token
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Caller (or curl):
|
||||
|
||||
```bash
|
||||
export SHORTENER_BASE_URL=http://127.0.0.1:8005
|
||||
export SHORTENER_API_TOKEN=monica:dev-only-token
|
||||
|
||||
curl -sS -X POST "${SHORTENER_BASE_URL}/api/links/" \
|
||||
-H "Authorization: Bearer ${SHORTENER_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"target_url":"https://mkdrealtor.com/","title":"test"}'
|
||||
```
|
||||
|
||||
Expect `201` and `short_url` like `http://127.0.0.1:8005/<code>`.
|
||||
|
||||
```bash
|
||||
curl -sSI "${SHORTENER_BASE_URL}/<code>"
|
||||
```
|
||||
|
||||
Expect `302` and `Location: https://mkdrealtor.com/`.
|
||||
|
||||
No header → `401`. `https://evil.com` → `400`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Minimal caller (Python)
|
||||
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
|
||||
BASE = os.environ["SHORTENER_BASE_URL"].rstrip("/")
|
||||
TOKEN = os.environ["SHORTENER_API_TOKEN"]
|
||||
|
||||
|
||||
def shorten(target_url: str, *, title: str = "", external_ref: str = "") -> str:
|
||||
response = requests.post(
|
||||
f"{BASE}/api/links/",
|
||||
headers={"Authorization": f"Bearer {TOKEN}"},
|
||||
json={
|
||||
"target_url": target_url,
|
||||
"title": title,
|
||||
"external_ref": external_ref,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["short_url"]
|
||||
```
|
||||
|
||||
Use `short_url` in the message body. On `401`/`503`, fail the send — do not
|
||||
fall back to pasting the API host into SMS.
|
||||
+28
@@ -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"]
|
||||
@@ -0,0 +1,504 @@
|
||||
# URL Shortening Service — Implementation
|
||||
|
||||
Django 6 service in Docker, packaged with `uv`. First caller is `monica_site` (SMS campaign
|
||||
bodies and ad-hoc texts). A short public domain will be pointed at this service later;
|
||||
until then a subdomain (for example `go.mkdrealtor.com`) is enough.
|
||||
|
||||
This document is the build spec. Implement in this repo. Do not fold the shortener into
|
||||
`monica_site`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals & non-goals
|
||||
|
||||
### Goals
|
||||
|
||||
- Mint a short HTTPS URL that 302s to a long HTTPS URL.
|
||||
- Public `GET /<code>` so SMS recipients can tap the link with no token.
|
||||
- Create / list / disable **only** via a Bearer-authenticated API (own hostname; may be public DNS).
|
||||
- Named, rotatable tokens so `monica_site` can be revoked without rotating every caller.
|
||||
- Target-host allowlist so a stolen token cannot mint open redirects off the short domain.
|
||||
- Click counts for campaign reporting.
|
||||
- Same deploy shape as `monica_site`: `uv`, Docker, Postgres in prod, Gitea CI, `server-infra`.
|
||||
|
||||
### Non-goals (v1)
|
||||
|
||||
- Public web UI or Django admin exposed on the short domain.
|
||||
- Query-string tokens (`?token=`). Header only.
|
||||
- Per-recipient unique codes (who clicked). Add later if needed.
|
||||
- Custom vanity slugs (`/openhouse`). Random codes only in v1.
|
||||
- Multi-tenant SaaS, rate-limit dashboards, QR generation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Security model (do this first)
|
||||
|
||||
Two surfaces. Mixing them is the failure mode.
|
||||
|
||||
| Surface | Who | Auth | Network |
|
||||
|---------|-----|------|---------|
|
||||
| `GET /<code>` | anyone with the SMS | **none** | public short hostname (NPM + TLS) |
|
||||
| `/api/links/` (all methods) | `monica_site` and other trusted callers | **Bearer** | own hostname — **may be public DNS**. Token is the lock. |
|
||||
|
||||
### 2.1 Public redirect
|
||||
|
||||
SMS recipients have no token. Do **not** put Bearer on `GET /<code>`. That path is the
|
||||
product.
|
||||
|
||||
Use **302**, not 301. 301 is cached forever and later click counts (and disable) stop
|
||||
working.
|
||||
|
||||
### 2.2 Authenticated API
|
||||
|
||||
- `Authorization: Bearer <named-token>` on every `/api/` request.
|
||||
- If `SHORTENER_API_TOKENS` is empty, API returns **503**. Fail closed.
|
||||
- Constant-time compare (`hmac.compare_digest`) against every configured token.
|
||||
- Named tokens: `monica:<secret>`, `admin:<secret>`. Store the name on the `ShortLink`
|
||||
row as `created_by_token`.
|
||||
- Generate secrets with `python -c "import secrets; print(secrets.token_urlsafe(32))"`.
|
||||
- Never put the token in the short URL, logs, or git. Do not reuse
|
||||
`DJANGO_SECRET_KEY` or any `monica_site` webhook secret.
|
||||
- Do not log the `Authorization` header (Gunicorn/NPM access logs). Strip or disable it.
|
||||
|
||||
### 2.3 Two public hostnames (Bearer is the lock on /api/)
|
||||
|
||||
`/api/links/` may have its own public DNS record. Anyone who finds that URL still
|
||||
cannot mint links without a configured Bearer token. Empty `SHORTENER_API_TOKENS`
|
||||
→ **503**. Missing/wrong token → **401**. Do not add CORS `*` (server-to-server only).
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
├─ NPM: SHORT_DOMAIN (mkd.to / go.mkdrealtor.com)
|
||||
│ location ~*^/[a-z0-9]{4,8}$ → gunicorn (redirects only)
|
||||
│ /api/* → 404 / drop
|
||||
│ /admin/ → 404 / drop
|
||||
│
|
||||
└─ NPM: API hostname (e.g. shortener.aimloperations.com)
|
||||
/api/links/ → gunicorn (Bearer required)
|
||||
/admin/ → 404 / drop
|
||||
```
|
||||
|
||||
- Public NPM for `SHORT_DOMAIN` must not proxy `/api/` or `/admin/`.
|
||||
- Public NPM for the API hostname proxies `/api/` only. Do not expose `/admin/`.
|
||||
- `monica_site` calls `SHORTENER_BASE_URL` (the API hostname), never the short
|
||||
hostname, to create links.
|
||||
- Django admin stays on `SHORT_ADMIN_HOSTS` (localhost). Not on the public API host.
|
||||
|
||||
### 2.4 Target allowlist
|
||||
|
||||
A valid Bearer still must not mint arbitrary redirects.
|
||||
|
||||
On create:
|
||||
|
||||
- `https` only (reject `http`, `javascript:`, `data:`, protocol-relative `//evil.com`).
|
||||
- Hostname must match `SHORT_ALLOWED_HOSTS` (exact or suffix, e.g. `mkdrealtor.com`).
|
||||
- Normalize / reject credentials in the URL (`https://user:pass@host`).
|
||||
|
||||
### 2.5 Host routing inside the app
|
||||
|
||||
Even if NPM is misconfigured, the Django process must refuse the wrong surface:
|
||||
|
||||
- `request.get_host()` in `SHORT_PUBLIC_HOSTS` → redirect view only. `/api/` → 404.
|
||||
- `request.get_host()` in `SHORT_API_HOSTS` → `/api/` (still Bearer). May be public DNS.
|
||||
- `request.get_host()` in `SHORT_ADMIN_HOSTS` → `/admin/` (localhost only by default).
|
||||
- `/healthz/` allowed on both public hosts. No secrets in the body.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
Phone SMS: https://mkd.to/a3k9
|
||||
│
|
||||
▼
|
||||
Nginx Proxy Manager (TLS)
|
||||
│
|
||||
▼
|
||||
url_shortening_service (this repo)
|
||||
Docker: gunicorn + uv
|
||||
│
|
||||
Host header?
|
||||
├─ public short host → GET /<code> 302 + Click row
|
||||
└─ API host (own DNS) → /api/links/ (Bearer)
|
||||
│
|
||||
▼
|
||||
Postgres (own DATABASE_URL)
|
||||
```
|
||||
|
||||
Caller (`monica_site`) is an HTTP client. It does not share this database.
|
||||
|
||||
Until the purchased short domain exists:
|
||||
|
||||
- Set `SHORT_DOMAIN=go.mkdrealtor.com` (or `localhost:8005` locally).
|
||||
- Codes do not change when DNS is swapped. Only `SHORT_DOMAIN` / `PUBLIC_SHORT_URL` change.
|
||||
|
||||
---
|
||||
|
||||
## 4. Stack
|
||||
|
||||
Mirror `monica_site` so deploy muscle memory is the same.
|
||||
|
||||
| Piece | Choice |
|
||||
|-------|--------|
|
||||
| Python | 3.12 |
|
||||
| Package manager | `uv` (`pyproject.toml` + committed `uv.lock`) |
|
||||
| Framework | Django 6 |
|
||||
| WSGI | gunicorn in prod/beta; `runserver` when `DJANGO_ENV=dev` |
|
||||
| Static | WhiteNoise (almost unused; keep for admin if enabled internally) |
|
||||
| DB | Postgres via `DATABASE_URL`; SQLite when unset (tests / local) |
|
||||
| Image | `python:3.12-slim` + copy `uv` from `ghcr.io/astral-sh/uv` |
|
||||
| Queue | none in v1 (no worker) |
|
||||
| Extra deps | none for codes (`secrets`); `psycopg[binary]`, `gunicorn`, `whitenoise` |
|
||||
|
||||
No Redis, Celery, or frontend build.
|
||||
|
||||
---
|
||||
|
||||
## 5. Project layout
|
||||
|
||||
```
|
||||
url_shortening_service/
|
||||
Implementation.md ← this file
|
||||
README.md
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
Dockerfile
|
||||
docker-compose.yml # web + postgres, hot reload
|
||||
docker-compose.prod.yml # no bundled postgres; DATABASE_URL from env
|
||||
.env.example
|
||||
.env.prod.example
|
||||
.gitignore
|
||||
.gitea/workflows/ci.yml
|
||||
scripts/docker-entrypoint.sh
|
||||
site/
|
||||
manage.py
|
||||
shortener/ # Django project package
|
||||
settings/
|
||||
__init__.py
|
||||
base.py
|
||||
dev.py
|
||||
beta.py
|
||||
prod.py
|
||||
urls.py
|
||||
wsgi.py
|
||||
asgi.py
|
||||
links/ # the app
|
||||
models.py
|
||||
views.py
|
||||
api.py
|
||||
auth.py
|
||||
services.py
|
||||
urls.py
|
||||
tests.py
|
||||
admin.py # optional; only reachable on API host if enabled
|
||||
core/
|
||||
views.py # healthz
|
||||
```
|
||||
|
||||
Settings split by `DJANGO_ENV` like `monica_site`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data model
|
||||
|
||||
### `ShortLink`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `id` | UUID PK | |
|
||||
| `code` | `CharField(8)`, unique, indexed | 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/<code>/`
|
||||
|
||||
Detail including `click_count`.
|
||||
|
||||
### `POST /api/links/<code>/disable/`
|
||||
|
||||
Set `is_active=false`. Idempotent. `200`.
|
||||
|
||||
No DELETE in v1. No PATCH of `target_url`.
|
||||
|
||||
### Auth helper
|
||||
|
||||
```python
|
||||
# links/auth.py
|
||||
# Parse Authorization: Bearer <token>
|
||||
# Split named tokens from SHORTENER_API_TOKENS (comma-separated name:secret)
|
||||
# compare_digest each secret
|
||||
# Return token name or None
|
||||
```
|
||||
|
||||
Decorator / mixin on all API views. Wrong or missing → `401` with
|
||||
`WWW-Authenticate: Bearer`. Do not distinguish "unknown token" vs "malformed" in the body.
|
||||
|
||||
---
|
||||
|
||||
## 8. Public redirect
|
||||
|
||||
`GET /<code>` on the public host.
|
||||
|
||||
1. Lookup code (404 if missing, inactive, or expired).
|
||||
2. Insert `Click`, bump `click_count` (best-effort).
|
||||
3. `HttpResponseRedirect(target_url)` — **302**.
|
||||
|
||||
Reject codes that do not match `^[a-z0-9]{4,8}$` with 404 (no extra work).
|
||||
|
||||
`GET /` on the public host: landing page (“URL shortening service”, credit
|
||||
`aimloperations.com`). Does not advertise the API.
|
||||
|
||||
`GET /debug/`: mint form, **DEBUG only**, never on `SHORT_PUBLIC_HOSTS`.
|
||||
|
||||
`GET /healthz/`: `{"status": "ok"}`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Environment
|
||||
|
||||
### This service
|
||||
|
||||
| Var | Purpose |
|
||||
|-----|---------|
|
||||
| `DJANGO_ENV` | `dev` / `beta` / `prod` |
|
||||
| `DJANGO_SECRET_KEY` | Django signing; **not** an API token |
|
||||
| `DJANGO_DEBUG` | false in prod |
|
||||
| `DJANGO_ALLOWED_HOSTS` | public short host **and** API hostname |
|
||||
| `DATABASE_URL` | Postgres; empty → SQLite |
|
||||
| `SHORT_DOMAIN` | public hostname phones use (`go.mkdrealtor.com` then `mkd.to`) |
|
||||
| `PUBLIC_SHORT_URL` | origin for minted URLs, e.g. `https://go.mkdrealtor.com` (no trailing slash) |
|
||||
| `SHORT_PUBLIC_HOSTS` | comma list; Host values that only serve redirects |
|
||||
| `SHORT_API_HOSTS` | comma list; Host values that serve `/api/` (public API DNS and/or docker name) |
|
||||
| `SHORT_ADMIN_HOSTS` | comma list; Host values that serve `/admin/` (default localhost only) |
|
||||
| `SHORTENER_API_TOKENS` | `name:secret,name:secret` — required for API |
|
||||
| `SHORT_ALLOWED_HOSTS` | allowlist for `target_url` hosts |
|
||||
| `SHORT_CODE_LENGTH` | default `6` |
|
||||
| `CLICK_IP_PEPPER` | HMAC key for `ip_hash`; distinct from `DJANGO_SECRET_KEY` |
|
||||
| `GUNICORN_WORKERS` | default `2` |
|
||||
|
||||
`.env.example` documents all of these. Prod secrets live in
|
||||
`~/Documents/secrets/url_shortening_service/` on the control node, same convention as
|
||||
`monica_site`.
|
||||
|
||||
### Caller (`monica_site`) — later, other repo
|
||||
|
||||
```text
|
||||
SHORTENER_BASE_URL=https://shortener.aimloperations.com
|
||||
SHORTENER_API_TOKEN=monica:<same-secret>
|
||||
```
|
||||
|
||||
`monica_site` sends `Authorization: Bearer monica:<secret>` to
|
||||
`POST {SHORTENER_BASE_URL}/api/links/` and substitutes `short_url` into SMS bodies
|
||||
**after** UTM is attached.
|
||||
|
||||
Do not implement the caller in this repo. Document the contract only.
|
||||
|
||||
---
|
||||
|
||||
## 10. Docker
|
||||
|
||||
### `Dockerfile`
|
||||
|
||||
Same pattern as `monica_site`:
|
||||
|
||||
- `FROM python:3.12-slim`
|
||||
- install `libpq5`, copy `uv`
|
||||
- `uv sync --frozen --no-dev`
|
||||
- copy `site/` + entrypoint
|
||||
- `EXPOSE 8000`
|
||||
|
||||
### `docker-compose.yml` (dev)
|
||||
|
||||
- `db`: Postgres 16
|
||||
- `web`: build `.`, mount `./site`, `DJANGO_ENV=dev` → runserver
|
||||
- **Do not** publish the host port to the LAN unless you are testing redirects.
|
||||
Prefer `127.0.0.1:8005:8000` (container still listens on 8000).
|
||||
- Internal API hostname: service name `web` (or `url-shortener`).
|
||||
|
||||
### `docker-compose.prod.yml`
|
||||
|
||||
- `web` only. External Postgres via `DATABASE_URL`.
|
||||
- `ports: "${WEB_PORT:-8005}:8000"` (pick a free host port; document it).
|
||||
- `env_file: .env`
|
||||
- No worker profile.
|
||||
|
||||
### Entrypoint
|
||||
|
||||
Wait for DB → `migrate --noinput` → if `DJANGO_ENV=dev` then `runserver`, else
|
||||
`collectstatic` + gunicorn.
|
||||
|
||||
---
|
||||
|
||||
## 11. NPM / deploy notes (ops, not code)
|
||||
|
||||
When the domains exist:
|
||||
|
||||
1. Public NPM proxy host = `SHORT_DOMAIN` → this 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://<that-domain>`.
|
||||
6. Second NPM proxy host = API hostname → same container. Proxy `/api/` only.
|
||||
7. Add the API hostname to `DJANGO_ALLOWED_HOSTS` and `SHORT_API_HOSTS`.
|
||||
|
||||
CI (same split as `monica_site`, default branch `master`):
|
||||
|
||||
- `.gitea/workflows/ci.yml` — unit tests on pull requests
|
||||
- `.gitea/workflows/deploy-beta.yml` — on push to `master`: unit tests → compose
|
||||
tests → `server-infra/scripts/deploy.sh --app url_shortening_service --env beta`
|
||||
- `.gitea/workflows/deploy-prod.yml` — `workflow_dispatch` (Actions button): same
|
||||
tests, then `--env prod`
|
||||
|
||||
---
|
||||
|
||||
## 12. Tests (required before merge)
|
||||
|
||||
Django `TestCase` / `SimpleTestCase`. No live network.
|
||||
|
||||
- Auth: missing Bearer → 401; wrong token → 401; empty `SHORTENER_API_TOKENS` → 503;
|
||||
matching named token → 201.
|
||||
- Host split: request to short host `/api/links/` → 404 even with valid Bearer.
|
||||
Public API host without Bearer → 401; valid Bearer → 201. `/admin/` 404 on API host.
|
||||
- Allowlist: `https://mkdrealtor.com/x` ok; `http://…` 400; `https://evil.com` 400;
|
||||
`javascript:alert(1)` 400.
|
||||
- Redirect: active code → 302 to target; inactive/expired/unknown → 404.
|
||||
- Redirect is 302, not 301.
|
||||
- Click row created and `click_count` incremented.
|
||||
- Idempotent create with same `target_url` + `external_ref`.
|
||||
- `short_url` uses `PUBLIC_SHORT_URL`, not the API Host header.
|
||||
- Code charset: generated codes only use the unambiguous alphabet.
|
||||
|
||||
---
|
||||
|
||||
## 13. Implementation order
|
||||
|
||||
1. `uv init` / `pyproject.toml` + Django project + settings/env helpers.
|
||||
2. `links` models + migration.
|
||||
3. `links/auth.py` + API views (create/list/detail/disable).
|
||||
4. Redirect view + host-split middleware.
|
||||
5. Dockerfile, compose, entrypoint, `.env.example`.
|
||||
6. Tests in §12.
|
||||
7. README: how to run locally, how a caller authenticates, what is public vs internal.
|
||||
8. Stop. Caller integration lives in `monica_site` after this service is up.
|
||||
|
||||
Phase 2 (not this pass): SMS auto-shorten in `monica_site` composer, custom slugs,
|
||||
per-recipient codes, internal-only admin UI.
|
||||
|
||||
---
|
||||
|
||||
## 14. Local run (target)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# set SHORTENER_API_TOKENS=monica:dev-only-token
|
||||
docker compose up --build
|
||||
# or:
|
||||
uv sync
|
||||
cd site && uv run python manage.py migrate
|
||||
uv run python manage.py runserver
|
||||
```
|
||||
|
||||
Mint (API host / localhost):
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://127.0.0.1:8005/api/links/ \
|
||||
-H "Authorization: Bearer monica:dev-only-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"target_url":"https://mkdrealtor.com/","title":"test"}'
|
||||
```
|
||||
|
||||
Follow (public path):
|
||||
|
||||
```bash
|
||||
curl -sSI http://127.0.0.1:8005/<code>
|
||||
```
|
||||
@@ -1,2 +1,131 @@
|
||||
# url_shortening_service
|
||||
|
||||
Django 6 URL shortener. `monica_site` (and other trusted callers) mint links over
|
||||
Bearer auth on an **API hostname** (may be public DNS). SMS recipients hit
|
||||
`GET /<code>` on the **short** domain and get a 302 to the long HTTPS URL.
|
||||
|
||||
This service is standalone. Do not fold it into `monica_site`.
|
||||
|
||||
## Two surfaces
|
||||
|
||||
| Who | Path | Auth | Host |
|
||||
|-----|------|------|------|
|
||||
| Phone / public internet | `GET /<code>` | none | `SHORT_DOMAIN` (NPM + TLS) |
|
||||
| Anyone | `GET /` | none | landing page on the short domain |
|
||||
| `monica_site` | `/api/links/` | `Authorization: Bearer name:secret` | own DNS / NPM host — **not** the short domain |
|
||||
|
||||
The API may be on the public internet. It is not open: every `/api/` request needs a
|
||||
named Bearer token. No token / wrong token → **401**. No tokens configured → **503**.
|
||||
`GET /<code>` never requires a token.
|
||||
|
||||
`/api/` and `/admin/` are 404 on the short domain. `/admin/` is also 404 on the
|
||||
public API hostname (localhost only). `GET /debug/` is a mint form when
|
||||
`DEBUG=true` and never on the short domain.
|
||||
|
||||
## Local run
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# SHORTENER_API_TOKENS=monica:dev-only-token is already set
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Or without Docker:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
cp .env.example .env
|
||||
cd site && uv run python manage.py migrate
|
||||
uv run python manage.py runserver
|
||||
# optional local admin:
|
||||
# uv run python manage.py createsuperuser
|
||||
# then http://127.0.0.1:8005/admin/ (compose) or :8000 (runserver)
|
||||
```
|
||||
|
||||
Tests (SQLite, no network):
|
||||
|
||||
```bash
|
||||
cd site && uv run python manage.py test
|
||||
```
|
||||
|
||||
## CI / deploy (Gitea)
|
||||
|
||||
Same split as `monica_site`:
|
||||
|
||||
| Workflow | When | What |
|
||||
|----------|------|------|
|
||||
| `CI` | pull request → `master` | unit tests |
|
||||
| `Deploy Beta` | push / merge to `master` | unit tests → compose tests → deploy **beta** |
|
||||
| `Deploy Prod` | **Actions → Run workflow** (button) | unit tests → compose tests → deploy **prod** |
|
||||
|
||||
Deploy calls `server-infra/scripts/deploy.sh --app url_shortening_service`. Needs [server-infra#22](https://git.aimloperations.com/ai_ml_operations/server-infra/issues/22) first.
|
||||
|
||||
## Use case
|
||||
|
||||
1. Your site POSTs to this service with a Bearer token and a long HTTPS URL.
|
||||
2. Response `201` includes `short_url` built from `PUBLIC_SHORT_URL` (not the API Host).
|
||||
3. A person on the public internet opens that short URL.
|
||||
4. This service 302s them to the long URL and increments `click_count`.
|
||||
|
||||
Mint (API host / localhost):
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://127.0.0.1:8005/api/links/ \
|
||||
-H "Authorization: Bearer monica:dev-only-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"target_url":"https://mkdrealtor.com/","title":"test"}'
|
||||
```
|
||||
|
||||
Follow (no token — this is the public path):
|
||||
|
||||
```bash
|
||||
curl -sSI http://127.0.0.1:8005/<code>
|
||||
```
|
||||
|
||||
Expect `HTTP/1.1 302 Found` and `Location: https://mkdrealtor.com/`.
|
||||
|
||||
Caller integration (onboard + `/api/links/`): **[API.md](API.md)**.
|
||||
|
||||
## Caller contract (`monica_site`, other repo)
|
||||
|
||||
```text
|
||||
SHORTENER_BASE_URL=https://shortener.aimloperations.com
|
||||
SHORTENER_API_TOKEN=monica:<same-secret-as-SHORTENER_API_TOKENS>
|
||||
```
|
||||
|
||||
```http
|
||||
POST /api/links/
|
||||
Authorization: Bearer monica:<secret>
|
||||
Content-Type: application/json
|
||||
|
||||
{"target_url":"https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms","title":"Oak St","external_ref":"campaign-uuid"}
|
||||
```
|
||||
|
||||
`target_url` must be `https` and its host must match `SHORT_ALLOWED_HOSTS`
|
||||
(exact or suffix, e.g. `mkdrealtor.com`). Same `target_url` + `external_ref` +
|
||||
still-active link returns `200` with the existing row instead of a new code.
|
||||
|
||||
Do not call the public short hostname to create links.
|
||||
|
||||
## Environment
|
||||
|
||||
See `.env.example` and `.env.prod.example`. Prod secrets live in
|
||||
`~/Documents/secrets/url_shortening_service/` on the control node.
|
||||
|
||||
Generate tokens and peppers with:
|
||||
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
```
|
||||
|
||||
Never reuse `DJANGO_SECRET_KEY` as an API token. Never put the token in the
|
||||
short URL, logs, or git.
|
||||
|
||||
## Deploy notes
|
||||
|
||||
Two NPM hosts, same container `WEB_PORT` (default 8005 prod / 8015 beta):
|
||||
|
||||
1. `SHORT_DOMAIN` (`aiml.pw` / `cidinn.li`) — `/` landing + `GET /[a-z0-9]{4,8}`.
|
||||
Drop `/api/`, `/admin/`, `/debug/`.
|
||||
2. API hostname — proxy `/api/` only. Drop `/admin/`. Add that Host to
|
||||
`DJANGO_ALLOWED_HOSTS` and `SHORT_API_HOSTS`.
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core"
|
||||
@@ -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()),
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ SITE_NAME }}{% endblock %}</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #1c1914;
|
||||
--paper: #f3ead8;
|
||||
--rule: #c9b896;
|
||||
--pine: #2a4a3c;
|
||||
--rust: #9a3f24;
|
||||
--muted: #6b6254;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
body {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: "Iowan Old Style", Palatino, "Palatino Linotype", "Times New Roman", serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.shell {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
padding: 3.5rem 1.5rem 2rem;
|
||||
}
|
||||
.eyebrow {
|
||||
font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pine);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(2.4rem, 8vw, 4.2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 0.95;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
p { margin: 0 0 1rem; font-size: 1.15rem; }
|
||||
.lede { color: var(--muted); max-width: 32rem; }
|
||||
a { color: var(--rust); }
|
||||
a:hover { color: var(--ink); }
|
||||
footer {
|
||||
margin-top: 3.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
footer a { color: inherit; }
|
||||
.banner {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
padding: 0.4rem 1.5rem;
|
||||
}
|
||||
form { margin-top: 1.75rem; display: grid; gap: 0.9rem; }
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pine);
|
||||
}
|
||||
input[type="url"], input[type="text"] {
|
||||
font: 1rem/1.4 "Iowan Old Style", Palatino, serif;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--rule);
|
||||
background: #fffdf6;
|
||||
color: var(--ink);
|
||||
}
|
||||
button, .btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: var(--pine);
|
||||
color: var(--paper);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.7rem 1rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
button:hover, .btn:hover { background: var(--ink); }
|
||||
.error { color: var(--rust); font-size: 0.95rem; }
|
||||
.result {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--pine);
|
||||
background: #fffdf6;
|
||||
}
|
||||
.result code {
|
||||
font-size: 1.05rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.messages { list-style: none; padding: 0; margin: 0 0 1rem; color: var(--pine); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if debug %}
|
||||
<div class="banner">Debug — create page is local only</div>
|
||||
{% endif %}
|
||||
<main class="shell">
|
||||
{% block content %}{% endblock %}
|
||||
<footer>
|
||||
Created by
|
||||
<a href="{{ CREDIT_URL }}">{{ CREDIT_NAME }}</a>
|
||||
{% if show_debug_create %}
|
||||
· <a href="{% url 'debug-create' %}">Create a short link</a>
|
||||
{% endif %}
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% block title %}{{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<p class="eyebrow">{{ SITE_NAME }}</p>
|
||||
<h1>{{ SHORT_DOMAIN }}</h1>
|
||||
<p class="lede">
|
||||
This is a URL shortening service. A path like
|
||||
<strong>/a3k9xm</strong> sends you to the long HTTPS URL.
|
||||
</p>
|
||||
<p class="lede">
|
||||
Created by <a href="{{ CREDIT_URL }}">{{ CREDIT_NAME }}</a>
|
||||
· <a href="{{ CREDIT_URL }}">aimloperations.com</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
from django.contrib import admin, messages
|
||||
from django.forms import ModelForm, ValidationError as FormValidationError
|
||||
from django.utils.html import format_html
|
||||
|
||||
from links.models import Click, ShortLink
|
||||
from links.services import ValidationError, generate_code, validate_target_url
|
||||
|
||||
admin.site.site_header = "URL shortener"
|
||||
admin.site.site_title = "Shortener admin"
|
||||
admin.site.index_title = "Links and clicks"
|
||||
|
||||
|
||||
class ShortLinkAdminForm(ModelForm):
|
||||
class Meta:
|
||||
model = ShortLink
|
||||
fields = "__all__"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if "code" in self.fields:
|
||||
self.fields["code"].required = False
|
||||
if "created_by_token" in self.fields:
|
||||
self.fields["created_by_token"].required = False
|
||||
|
||||
def clean_target_url(self):
|
||||
raw = self.cleaned_data.get("target_url") or ""
|
||||
try:
|
||||
return validate_target_url(raw)
|
||||
except ValidationError as exc:
|
||||
raise FormValidationError(str(exc)) from exc
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
if not self.instance.pk and not cleaned.get("code"):
|
||||
cleaned["code"] = generate_code()
|
||||
if not cleaned.get("created_by_token"):
|
||||
cleaned["created_by_token"] = "admin"
|
||||
return cleaned
|
||||
|
||||
|
||||
class ClickInline(admin.TabularInline):
|
||||
model = Click
|
||||
extra = 0
|
||||
can_delete = False
|
||||
show_change_link = True
|
||||
max_num = 0
|
||||
readonly_fields = ("occurred_at", "ip_hash", "user_agent", "referrer")
|
||||
fields = ("occurred_at", "ip_hash", "user_agent", "referrer")
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(ShortLink)
|
||||
class ShortLinkAdmin(admin.ModelAdmin):
|
||||
form = ShortLinkAdminForm
|
||||
list_display = (
|
||||
"code",
|
||||
"short_url_display",
|
||||
"target_url",
|
||||
"title",
|
||||
"is_active",
|
||||
"click_count",
|
||||
"created_by_token",
|
||||
"expires_at",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("is_active", "created_by_token", "created_at")
|
||||
search_fields = ("code", "target_url", "external_ref", "title")
|
||||
date_hierarchy = "created_at"
|
||||
ordering = ("-created_at",)
|
||||
list_per_page = 50
|
||||
actions = ("disable_links",)
|
||||
inlines = (ClickInline,)
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"code",
|
||||
"created_by_token",
|
||||
"click_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"short_url_display",
|
||||
)
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"code",
|
||||
"short_url_display",
|
||||
"target_url",
|
||||
"title",
|
||||
"external_ref",
|
||||
"is_active",
|
||||
"expires_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Attribution",
|
||||
{"fields": ("created_by_token", "click_count", "created_at", "updated_at", "id")},
|
||||
),
|
||||
)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if obj:
|
||||
return self.readonly_fields + ("target_url",)
|
||||
return self.readonly_fields
|
||||
|
||||
@admin.display(description="Short URL")
|
||||
def short_url_display(self, obj: ShortLink) -> str:
|
||||
if not obj.code:
|
||||
return "—"
|
||||
return format_html('<a href="{0}">{0}</a>', obj.public_short_url)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change:
|
||||
if not obj.code:
|
||||
obj.code = generate_code()
|
||||
if not obj.created_by_token:
|
||||
obj.created_by_token = (
|
||||
request.user.get_username() if request.user.is_authenticated else "admin"
|
||||
)
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
@admin.action(description="Disable selected links")
|
||||
def disable_links(self, request, queryset):
|
||||
updated = queryset.filter(is_active=True).update(is_active=False)
|
||||
self.message_user(
|
||||
request,
|
||||
f"Disabled {updated} link(s).",
|
||||
messages.SUCCESS if updated else messages.INFO,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Click)
|
||||
class ClickAdmin(admin.ModelAdmin):
|
||||
list_display = ("link", "occurred_at", "ip_hash_short", "user_agent_short")
|
||||
list_filter = ("occurred_at",)
|
||||
search_fields = ("link__code", "ip_hash", "user_agent", "referrer")
|
||||
date_hierarchy = "occurred_at"
|
||||
readonly_fields = ("id", "link", "occurred_at", "ip_hash", "user_agent", "referrer")
|
||||
ordering = ("-occurred_at",)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
@admin.display(description="IP hash")
|
||||
def ip_hash_short(self, obj: Click) -> str:
|
||||
return (obj.ip_hash[:12] + "…") if obj.ip_hash else "—"
|
||||
|
||||
@admin.display(description="User agent")
|
||||
def user_agent_short(self, obj: Click) -> str:
|
||||
ua = obj.user_agent or ""
|
||||
return (ua[:48] + "…") if len(ua) > 48 else (ua or "—")
|
||||
@@ -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))
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LinksConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "links"
|
||||
@@ -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
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Link minting, target-URL allowlist, click recording."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import F
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from links.models import Click, ShortLink
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_CODE_ATTEMPTS = 8
|
||||
|
||||
|
||||
class ValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class CodeCollisionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def host_allowed(hostname: str, allowed: list[str]) -> bool:
|
||||
hostname = hostname.lower().rstrip(".")
|
||||
for entry in allowed:
|
||||
entry = entry.lower().strip()
|
||||
if entry.startswith("*."):
|
||||
entry = entry[2:]
|
||||
entry = entry.lstrip(".").rstrip(".")
|
||||
if not entry:
|
||||
continue
|
||||
if hostname == entry or hostname.endswith("." + entry):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_target_url(raw: str) -> str:
|
||||
"""Return a canonical https URL or raise ValidationError."""
|
||||
if not raw or not isinstance(raw, str):
|
||||
raise ValidationError("invalid url")
|
||||
raw = raw.strip()
|
||||
if raw.startswith("//"):
|
||||
raise ValidationError("invalid url")
|
||||
|
||||
try:
|
||||
parts = urlsplit(raw)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("invalid url") from exc
|
||||
|
||||
if parts.scheme.lower() != "https":
|
||||
raise ValidationError("invalid url")
|
||||
if parts.username or parts.password:
|
||||
raise ValidationError("invalid url")
|
||||
|
||||
hostname = (parts.hostname or "").lower().rstrip(".")
|
||||
if not hostname:
|
||||
raise ValidationError("invalid url")
|
||||
|
||||
allowed = list(getattr(settings, "SHORT_ALLOWED_HOSTS", []) or [])
|
||||
if not host_allowed(hostname, allowed):
|
||||
raise ValidationError("host not allowlisted")
|
||||
|
||||
netloc = hostname
|
||||
if parts.port:
|
||||
netloc = f"{hostname}:{parts.port}"
|
||||
return urlunsplit(("https", netloc, parts.path, parts.query, parts.fragment))
|
||||
|
||||
|
||||
def generate_code(length: int | None = None) -> str:
|
||||
alphabet = settings.CODE_ALPHABET
|
||||
size = length if length is not None else settings.SHORT_CODE_LENGTH
|
||||
return "".join(secrets.choice(alphabet) for _ in range(size))
|
||||
|
||||
|
||||
def mint_unique_code() -> str:
|
||||
for _ in range(MAX_CODE_ATTEMPTS):
|
||||
code = generate_code()
|
||||
if not ShortLink.objects.filter(code=code).exists():
|
||||
return code
|
||||
raise CodeCollisionError("could not allocate a unique code")
|
||||
|
||||
|
||||
def parse_expires_at(value) -> datetime | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError("invalid expires_at")
|
||||
parsed = parse_datetime(value)
|
||||
if parsed is None:
|
||||
raise ValidationError("invalid expires_at")
|
||||
if timezone.is_naive(parsed):
|
||||
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
|
||||
return parsed
|
||||
|
||||
|
||||
def find_idempotent_link(target_url: str, external_ref: str) -> ShortLink | None:
|
||||
if not external_ref:
|
||||
return None
|
||||
qs = ShortLink.objects.filter(
|
||||
target_url=target_url,
|
||||
external_ref=external_ref,
|
||||
is_active=True,
|
||||
)
|
||||
now = timezone.now()
|
||||
for link in qs:
|
||||
if link.expires_at is None or link.expires_at > now:
|
||||
return link
|
||||
return None
|
||||
|
||||
|
||||
def create_link(
|
||||
*,
|
||||
target_url: str,
|
||||
title: str,
|
||||
external_ref: str,
|
||||
expires_at: datetime | None,
|
||||
token_name: str,
|
||||
) -> tuple[ShortLink, bool]:
|
||||
"""Return ``(link, created)``. ``created`` is False on idempotent hit."""
|
||||
existing = find_idempotent_link(target_url, external_ref)
|
||||
if existing:
|
||||
return existing, False
|
||||
|
||||
for _ in range(MAX_CODE_ATTEMPTS):
|
||||
try:
|
||||
link = ShortLink.objects.create(
|
||||
code=generate_code(),
|
||||
target_url=target_url,
|
||||
title=title,
|
||||
external_ref=external_ref,
|
||||
expires_at=expires_at,
|
||||
created_by_token=token_name,
|
||||
)
|
||||
return link, True
|
||||
except IntegrityError:
|
||||
continue
|
||||
raise CodeCollisionError("could not allocate a unique code")
|
||||
|
||||
|
||||
def client_ip(request: HttpRequest) -> str:
|
||||
forwarded = request.META.get("HTTP_X_FORWARDED_FOR") or ""
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return (request.META.get("REMOTE_ADDR") or "").strip()
|
||||
|
||||
|
||||
def hash_ip(ip: str) -> str:
|
||||
pepper = getattr(settings, "CLICK_IP_PEPPER", "") or ""
|
||||
if not ip or not pepper:
|
||||
return ""
|
||||
return hmac.new(pepper.encode(), ip.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def record_click(request: HttpRequest, link: ShortLink) -> None:
|
||||
Click.objects.create(
|
||||
link=link,
|
||||
ip_hash=hash_ip(client_ip(request)),
|
||||
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:512],
|
||||
referrer=(request.META.get("HTTP_REFERER") or "")[:1024],
|
||||
)
|
||||
ShortLink.objects.filter(pk=link.pk).update(click_count=F("click_count") + 1)
|
||||
|
||||
|
||||
def record_click_best_effort(request: HttpRequest, link: ShortLink) -> None:
|
||||
try:
|
||||
record_click(request, link)
|
||||
except Exception:
|
||||
logger.exception("click record failed for code=%s", link.code)
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% block title %}Create short link · debug{% endblock %}
|
||||
{% block content %}
|
||||
<p class="eyebrow">Debug only</p>
|
||||
<h1>Create a short link</h1>
|
||||
<p class="lede">Uses the same allowlist as the API. Not served when <code>DEBUG</code> is false.</p>
|
||||
|
||||
{% if messages %}
|
||||
<ul class="messages">
|
||||
{% for message in messages %}<li>{{ message }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if created_link %}
|
||||
<div class="result">
|
||||
<p>Short URL</p>
|
||||
<p><code>{{ created_link.public_short_url }}</code></p>
|
||||
<p>Target: {{ created_link.target_url }}</p>
|
||||
<a class="btn" href="{{ created_link.public_short_url }}">Open</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'debug-create' %}">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
{% for field in form %}
|
||||
<label>
|
||||
{{ field.label }}
|
||||
{{ field }}
|
||||
{% if field.errors %}<span class="error">{{ field.errors|striptags }}</span>{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
<button type="submit">Mint</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from links import api
|
||||
|
||||
urlpatterns = [
|
||||
path("", api.links_collection, name="links-collection"),
|
||||
path("<str:code>/", api.link_detail, name="links-detail"),
|
||||
path("<str:code>/disable/", api.link_disable, name="links-disable"),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Public redirect — no auth. SMS recipients tap GET /<code>."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from links.models import ShortLink
|
||||
from links.services import record_click_best_effort
|
||||
|
||||
CODE_RE = re.compile(r"^[a-z0-9]{4,8}$")
|
||||
|
||||
|
||||
@require_http_methods(["GET", "HEAD"])
|
||||
def redirect_view(request, code: str):
|
||||
if not CODE_RE.fullmatch(code):
|
||||
raise Http404()
|
||||
|
||||
try:
|
||||
link = ShortLink.objects.get(code=code)
|
||||
except ShortLink.DoesNotExist as exc:
|
||||
raise Http404() from exc
|
||||
|
||||
if not link.is_available():
|
||||
raise Http404()
|
||||
|
||||
if request.method == "GET":
|
||||
record_click_best_effort(request, link)
|
||||
return HttpResponseRedirect(link.target_url)
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -0,0 +1,17 @@
|
||||
"""URL configuration for shortener."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
from core.views import healthz, landing
|
||||
from links.debug_views import debug_create
|
||||
from links.views import redirect_view
|
||||
|
||||
urlpatterns = [
|
||||
path("", landing, name="landing"),
|
||||
path("healthz/", healthz, name="healthz"),
|
||||
path("debug/", debug_create, name="debug-create"),
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/links/", include("links.urls")),
|
||||
path("<str:code>", redirect_view, name="redirect"),
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user