Add v1 shortener: Bearer API, public 302, landing, and CI.
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:
2026-08-30 06:54:32 -05:00
parent 4baaa4b33c
commit 143ac7c6d0
48 changed files with 3469 additions and 2 deletions
+289
View File
@@ -0,0 +1,289 @@
# URL shortener — caller API
For other services (`monica_site`, later callers) that mint short links.
This service has two public hostnames. **Call the API host.** Never create links
on the short domain (`aiml.pw` / `cidinn.li` / `go.mkdrealtor.com`). That host
only serves `GET /` (landing) and `GET /<code>` (302). `/api/` there is **404**.
| Host | Example | What you call |
|------|---------|----------------|
| API | `https://shortener.aimloperations.com` | `POST/GET /api/links/` |
| Short | `https://aiml.pw` (or `cidinn.li`) | phones only — `GET /<code>` |
Local compose: API + redirects on `http://127.0.0.1:8005`.
JSON in/out. CSRF-exempt. **Server-to-server only** — no CORS `*`. Do not call
this from a browser.
---
## 1. Onboard a new caller
Two sides. A named token is the only lock. Anyone who has the URL still cannot
mint without it.
### 1a. This service (operator)
1. Generate a secret (do **not** reuse `DJANGO_SECRET_KEY` or a webhook secret):
```bash
python -c "import secrets; print(secrets.token_urlsafe(32))"
```
2. Pick a short token **name** for the caller (`monica`, `scha`, `chat`, …).
Revoking one name does not rotate the others.
3. Append `name:secret` to `SHORTENER_API_TOKENS` (comma-separated). Redeploy
or restart so settings reload.
```text
SHORTENER_API_TOKENS=monica:<secret>,scha:<other-secret>
```
Prod/beta files:
```text
~/Documents/secrets/url_shortening_service/url_shortening_service_prod.env
~/Documents/secrets/url_shortening_service/url_shortening_service_beta.env
```
4. If the callers destination hosts are not already allowed, add them to
`SHORT_ALLOWED_HOSTS` (exact or suffix). Example: `mkdrealtor.com` also
allows `www.mkdrealtor.com`. `https` only.
5. Give the caller **only**:
- API origin (`SHORTENER_BASE_URL`)
- The full token string `name:secret` (they send it as Bearer)
Never put the token in git, logs, or the short URL.
Empty `SHORTENER_API_TOKENS` → every `/api/` request is **503** (fail closed).
### 1b. Caller service (your repo)
Add to that apps env (not this repo):
```text
# Prod
SHORTENER_BASE_URL=https://shortener.aimloperations.com
SHORTENER_API_TOKEN=monica:<same-secret>
# Beta (when that host exists)
# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com
# SHORTENER_API_TOKEN=monica:<beta-secret>
# Local (this service via compose)
# SHORTENER_BASE_URL=http://127.0.0.1:8005
# SHORTENER_API_TOKEN=monica:dev-only-token
```
Send:
```http
Authorization: Bearer monica:<secret>
```
That value must match an entry in this services `SHORTENER_API_TOKENS`
(`name:secret`). Bare secret also works; prefer `name:secret`.
The token **name** (`monica`) is stored on the row as `created_by_token`.
The secret is never stored.
Attach UTM (or any query string) on `target_url` **before** you mint. The
short code is a pointer; it does not rewrite query params later.
---
## 2. Auth
Every `/api/` route requires:
```http
Authorization: Bearer <name>:<secret>
Content-Type: application/json
```
| Situation | Status | Body |
|-----------|--------|------|
| Missing or wrong Bearer | **401** | `{"detail":"Unauthorized"}` + `WWW-Authenticate: Bearer` |
| No tokens configured on the server | **503** | `{"detail":"Service unavailable"}` |
| Valid token | continues | — |
401 does not distinguish “unknown token” vs “malformed header”.
---
## 3. `POST /api/links/`
Mint a short link.
### Request
```http
POST /api/links/
Authorization: Bearer monica:<secret>
Content-Type: application/json
```
```json
{
"target_url": "https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms",
"title": "Oak St listing",
"external_ref": "campaign-uuid-optional",
"expires_at": null
}
```
| Field | Required | Notes |
|-------|----------|--------|
| `target_url` | yes | `https` only. Host must match `SHORT_ALLOWED_HOSTS`. No `http`, `javascript:`, `data:`, `//evil.com`, or `user:pass@host`. |
| `title` | no | Note, max 200 chars. |
| `external_ref` | no | Your id (campaign UUID), max 64. Empty → always mint a **new** code. |
| `expires_at` | no | ISO-8601 datetime or `null`. After this, `GET /<code>` is 404. |
### Response `201` (new)
```json
{
"code": "a3k9xm",
"short_url": "https://aiml.pw/a3k9xm",
"target_url": "https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms",
"title": "Oak St listing",
"is_active": true,
"click_count": 0,
"created_at": "2026-08-30T10:00:00Z"
}
```
`short_url` is built from this services `PUBLIC_SHORT_URL` (the domain phones
hit). It is **not** the API host. Put `short_url` in SMS / email as-is.
### Idempotency `200`
Same `target_url` + non-empty `external_ref` + still-active (and unexpired)
link → existing row, **200**, no new code.
Empty `external_ref` → always **201** and a new code.
### Errors
| Status | When |
|--------|------|
| 400 | Invalid JSON, bad URL, host not allowlisted, bad `expires_at` / title / ref |
| 401 | Auth |
| 503 | No tokens on server |
| 500 | Could not allocate a unique code (rare) |
There is no PATCH of `target_url` and no DELETE. Disable instead.
---
## 4. `GET /api/links/`
List. Query params:
| Param | Default | Notes |
|-------|---------|--------|
| `external_ref` | — | Exact match |
| `is_active` | — | `true` / `false` (or `1` / `0`) |
| `limit` | 20 | Capped at 100 |
| `offset` | 0 | |
```json
{
"count": 1,
"limit": 20,
"offset": 0,
"results": [ { "code": "a3k9xm", "short_url": "…", "…": "…" } ]
}
```
---
## 5. `GET /api/links/<code>/`
One link, including `click_count`. **404** if the code does not exist.
---
## 6. `POST /api/links/<code>/disable/`
Sets `is_active=false`. Idempotent. **200** with the updated row.
After disable, public `GET /<code>` is **404** (no redirect).
---
## 7. What the public does (not the API)
`GET https://<SHORT_DOMAIN>/<code>` — no token.
- Active + unexpired → **302** to `target_url` (not 301). Click counted.
- Missing / disabled / expired / bad shape → **404**.
- `GET /` → landing page.
Do not put the Bearer token on this URL.
---
## 8. Local smoke
This service:
```bash
cp .env.example .env # SHORTENER_API_TOKENS=monica:dev-only-token
docker compose up --build
```
Caller (or curl):
```bash
export SHORTENER_BASE_URL=http://127.0.0.1:8005
export SHORTENER_API_TOKEN=monica:dev-only-token
curl -sS -X POST "${SHORTENER_BASE_URL}/api/links/" \
-H "Authorization: Bearer ${SHORTENER_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"target_url":"https://mkdrealtor.com/","title":"test"}'
```
Expect `201` and `short_url` like `http://127.0.0.1:8005/<code>`.
```bash
curl -sSI "${SHORTENER_BASE_URL}/<code>"
```
Expect `302` and `Location: https://mkdrealtor.com/`.
No header → `401`. `https://evil.com` → `400`.
---
## 9. Minimal caller (Python)
```python
import os
import requests
BASE = os.environ["SHORTENER_BASE_URL"].rstrip("/")
TOKEN = os.environ["SHORTENER_API_TOKEN"]
def shorten(target_url: str, *, title: str = "", external_ref: str = "") -> str:
response = requests.post(
f"{BASE}/api/links/",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"target_url": target_url,
"title": title,
"external_ref": external_ref,
},
timeout=10,
)
response.raise_for_status()
return response.json()["short_url"]
```
Use `short_url` in the message body. On `401`/`503`, fail the send — do not
fall back to pasting the API host into SMS.