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
|
# Rope project settings
|
||||||
.ropeproject
|
.ropeproject
|
||||||
|
|
||||||
# mkdocs documentation
|
# mkdocs documentation (do not ignore ./site — that is the Django project)
|
||||||
/site
|
/mkdocs_site
|
||||||
|
|
||||||
# mypy
|
# mypy
|
||||||
.mypy_cache/
|
.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
|
# 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