## Summary - Standalone Django 6 shortener: Bearer `/api/links/` (create/list/detail/disable) and public `GET /<code>` 302 - Host split, target-host allowlist, named rotatable tokens; `short_url` from `PUBLIC_SHORT_URL` - Landing page, DEBUG-only `/debug/` mint form, Django admin - Docker/compose (host **8005**), Gitea CI like monica_site (PR tests, beta on merge, prod button) - Caller contract in `API.md` Closes #1. Infra follow-up: [server-infra#22](ai_ml_operations/server-infra#22). ## Test plan - [ ] `cd site && uv run python manage.py test` - [ ] `docker compose up --build` → http://127.0.0.1:8005/ - [ ] `POST /api/links/` with `Bearer monica:dev-only-token` → 201 - [ ] `GET /<code>` → 302 to allowlisted https URL - [ ] No Bearer → 401; non-allowlisted host → 400 - [ ] `/debug/` only when `DEBUG=true` Reviewed-on: #2
157 lines
4.4 KiB
Python
157 lines
4.4 KiB
Python
"""Internal JSON API for minting and managing short links."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from django.http import Http404, HttpRequest, JsonResponse
|
|
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
|
|
|
from links.auth import require_bearer
|
|
from links.models import ShortLink
|
|
from links.services import (
|
|
CodeCollisionError,
|
|
ValidationError,
|
|
create_link,
|
|
parse_expires_at,
|
|
validate_target_url,
|
|
)
|
|
|
|
|
|
def _json_body(request: HttpRequest) -> dict | None:
|
|
if not request.body:
|
|
return {}
|
|
try:
|
|
data = json.loads(request.body)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
if not isinstance(data, dict):
|
|
return None
|
|
return data
|
|
|
|
|
|
def _iso(dt) -> str | None:
|
|
if dt is None:
|
|
return None
|
|
return dt.isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def serialize_link(link: ShortLink) -> dict:
|
|
from django.conf import settings
|
|
|
|
origin = (settings.PUBLIC_SHORT_URL or "").rstrip("/")
|
|
return {
|
|
"code": link.code,
|
|
"short_url": f"{origin}/{link.code}",
|
|
"target_url": link.target_url,
|
|
"title": link.title,
|
|
"is_active": link.is_active,
|
|
"click_count": link.click_count,
|
|
"created_at": _iso(link.created_at),
|
|
}
|
|
|
|
|
|
@require_bearer
|
|
@require_http_methods(["GET", "POST"])
|
|
def links_collection(request: HttpRequest):
|
|
if request.method == "POST":
|
|
return _create(request)
|
|
return _list(request)
|
|
|
|
|
|
def _create(request: HttpRequest) -> JsonResponse:
|
|
data = _json_body(request)
|
|
if data is None:
|
|
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
|
|
raw_url = data.get("target_url")
|
|
try:
|
|
target_url = validate_target_url(raw_url if isinstance(raw_url, str) else "")
|
|
expires_at = parse_expires_at(data.get("expires_at"))
|
|
except ValidationError as exc:
|
|
return JsonResponse({"detail": str(exc)}, status=400)
|
|
|
|
title = data.get("title") or ""
|
|
if not isinstance(title, str):
|
|
return JsonResponse({"detail": "invalid title"}, status=400)
|
|
title = title[:200]
|
|
|
|
external_ref = data.get("external_ref") or ""
|
|
if not isinstance(external_ref, str):
|
|
return JsonResponse({"detail": "invalid external_ref"}, status=400)
|
|
external_ref = external_ref[:64]
|
|
|
|
try:
|
|
link, created = create_link(
|
|
target_url=target_url,
|
|
title=title,
|
|
external_ref=external_ref,
|
|
expires_at=expires_at,
|
|
token_name=request.token_name,
|
|
)
|
|
except CodeCollisionError:
|
|
return JsonResponse({"detail": "could not allocate a unique code"}, status=500)
|
|
|
|
return JsonResponse(serialize_link(link), status=201 if created else 200)
|
|
|
|
|
|
def _list(request: HttpRequest) -> JsonResponse:
|
|
qs = ShortLink.objects.all()
|
|
|
|
external_ref = request.GET.get("external_ref")
|
|
if external_ref is not None:
|
|
qs = qs.filter(external_ref=external_ref)
|
|
|
|
is_active = request.GET.get("is_active")
|
|
if is_active is not None:
|
|
lowered = is_active.lower()
|
|
if lowered in {"true", "1"}:
|
|
qs = qs.filter(is_active=True)
|
|
elif lowered in {"false", "0"}:
|
|
qs = qs.filter(is_active=False)
|
|
else:
|
|
return JsonResponse({"detail": "invalid is_active"}, status=400)
|
|
|
|
try:
|
|
limit = int(request.GET.get("limit", 20))
|
|
offset = int(request.GET.get("offset", 0))
|
|
except (TypeError, ValueError):
|
|
return JsonResponse({"detail": "invalid pagination"}, status=400)
|
|
|
|
limit = min(max(limit, 0), 100)
|
|
offset = max(offset, 0)
|
|
|
|
total = qs.count()
|
|
rows = list(qs[offset : offset + limit])
|
|
return JsonResponse(
|
|
{
|
|
"count": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"results": [serialize_link(link) for link in rows],
|
|
}
|
|
)
|
|
|
|
|
|
@require_bearer
|
|
@require_GET
|
|
def link_detail(request: HttpRequest, code: str):
|
|
try:
|
|
link = ShortLink.objects.get(code=code)
|
|
except ShortLink.DoesNotExist as exc:
|
|
raise Http404() from exc
|
|
return JsonResponse(serialize_link(link))
|
|
|
|
|
|
@require_bearer
|
|
@require_POST
|
|
def link_disable(request: HttpRequest, code: str):
|
|
try:
|
|
link = ShortLink.objects.get(code=code)
|
|
except ShortLink.DoesNotExist as exc:
|
|
raise Http404() from exc
|
|
if link.is_active:
|
|
link.is_active = False
|
|
link.save(update_fields=["is_active", "updated_at"])
|
|
return JsonResponse(serialize_link(link))
|