Files
url_shortening_service/API.md
T
westfarn 433308d615
Deploy Beta / unit-tests (push) Successful in 4s
Deploy Beta / docker (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 1m3s
Serve /api/ on piha.lc / beta.piha.li (#8)
## Summary
- Serve `/api/` on `piha.lc` / `beta.piha.li` (Bearer still required).
- Drop `shortener.aimloperations.com` / `shortener-beta.aimloperations.com` from env examples and caller docs.
- `/admin/` stays 404 on the public host.

Closes #7.

## Test plan
- [ ] `POST https://beta.piha.li/api/links/` with valid Bearer → 201
- [ ] Same without Bearer → 401
- [ ] `GET https://beta.piha.li/<code>` still 302
- [ ] `/admin/` on beta.piha.li → 404
- [ ] Unit tests: `cd site && uv run python manage.py test`

Reviewed-on: #8
2026-08-30 17:27:40 -07:00

289 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# URL shortener — caller API
For other services (`monica_site`, later callers) that mint short links.
This service has one public hostname. Call it for minting **and** for
redirects. Prod: `https://piha.lc`. Beta: `https://beta.piha.li`.
| Host | Example | What you call |
|------|---------|----------------|
| Prod | `https://piha.lc` | `POST /api/links/` and `GET /<code>` |
| Beta | `https://beta.piha.li` | same |
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**:
- Origin (`SHORTENER_BASE_URL` — `https://piha.lc` or `https://beta.piha.li`)
- 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://piha.lc
SHORTENER_API_TOKEN=monica:<same-secret>
# Beta
# SHORTENER_BASE_URL=https://beta.piha.li
# 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://piha.lc/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` (same origin you
called). 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 a long URL unless that is your explicit fallback.