Files
url_shortening_service/API.md
T
westfarn bb1aa449d6
CI / test (pull_request) Successful in 4s
Restyle landing to match aimloperations.com and use piha.lc.
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.
2026-08-30 08:34:10 -05:00

290 lines
7.4 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 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 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://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` (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.