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,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.
|
||||
Reference in New Issue
Block a user