CI / test (pull_request) Successful in 4s
Public short hosts are piha.lc (prod) and beta.piha.li (beta). The root page is a marketing CTA to contact AI ML Operations for access. Closes #3.
290 lines
7.4 KiB
Markdown
290 lines
7.4 KiB
Markdown
# 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 (`piha.lc` / `beta.piha.li`). 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://piha.lc` (beta: `https://beta.piha.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://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 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.
|