Add v1 shortener: Bearer API, public 302, landing, and CI.
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:
2026-08-30 06:54:32 -05:00
parent 4baaa4b33c
commit 143ac7c6d0
48 changed files with 3469 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
+158
View File
@@ -0,0 +1,158 @@
from django.contrib import admin, messages
from django.forms import ModelForm, ValidationError as FormValidationError
from django.utils.html import format_html
from links.models import Click, ShortLink
from links.services import ValidationError, generate_code, validate_target_url
admin.site.site_header = "URL shortener"
admin.site.site_title = "Shortener admin"
admin.site.index_title = "Links and clicks"
class ShortLinkAdminForm(ModelForm):
class Meta:
model = ShortLink
fields = "__all__"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if "code" in self.fields:
self.fields["code"].required = False
if "created_by_token" in self.fields:
self.fields["created_by_token"].required = False
def clean_target_url(self):
raw = self.cleaned_data.get("target_url") or ""
try:
return validate_target_url(raw)
except ValidationError as exc:
raise FormValidationError(str(exc)) from exc
def clean(self):
cleaned = super().clean()
if not self.instance.pk and not cleaned.get("code"):
cleaned["code"] = generate_code()
if not cleaned.get("created_by_token"):
cleaned["created_by_token"] = "admin"
return cleaned
class ClickInline(admin.TabularInline):
model = Click
extra = 0
can_delete = False
show_change_link = True
max_num = 0
readonly_fields = ("occurred_at", "ip_hash", "user_agent", "referrer")
fields = ("occurred_at", "ip_hash", "user_agent", "referrer")
def has_add_permission(self, request, obj=None):
return False
@admin.register(ShortLink)
class ShortLinkAdmin(admin.ModelAdmin):
form = ShortLinkAdminForm
list_display = (
"code",
"short_url_display",
"target_url",
"title",
"is_active",
"click_count",
"created_by_token",
"expires_at",
"created_at",
)
list_filter = ("is_active", "created_by_token", "created_at")
search_fields = ("code", "target_url", "external_ref", "title")
date_hierarchy = "created_at"
ordering = ("-created_at",)
list_per_page = 50
actions = ("disable_links",)
inlines = (ClickInline,)
readonly_fields = (
"id",
"code",
"created_by_token",
"click_count",
"created_at",
"updated_at",
"short_url_display",
)
fieldsets = (
(
None,
{
"fields": (
"code",
"short_url_display",
"target_url",
"title",
"external_ref",
"is_active",
"expires_at",
)
},
),
(
"Attribution",
{"fields": ("created_by_token", "click_count", "created_at", "updated_at", "id")},
),
)
def get_readonly_fields(self, request, obj=None):
if obj:
return self.readonly_fields + ("target_url",)
return self.readonly_fields
@admin.display(description="Short URL")
def short_url_display(self, obj: ShortLink) -> str:
if not obj.code:
return ""
return format_html('<a href="{0}">{0}</a>', obj.public_short_url)
def save_model(self, request, obj, form, change):
if not change:
if not obj.code:
obj.code = generate_code()
if not obj.created_by_token:
obj.created_by_token = (
request.user.get_username() if request.user.is_authenticated else "admin"
)
super().save_model(request, obj, form, change)
@admin.action(description="Disable selected links")
def disable_links(self, request, queryset):
updated = queryset.filter(is_active=True).update(is_active=False)
self.message_user(
request,
f"Disabled {updated} link(s).",
messages.SUCCESS if updated else messages.INFO,
)
@admin.register(Click)
class ClickAdmin(admin.ModelAdmin):
list_display = ("link", "occurred_at", "ip_hash_short", "user_agent_short")
list_filter = ("occurred_at",)
search_fields = ("link__code", "ip_hash", "user_agent", "referrer")
date_hierarchy = "occurred_at"
readonly_fields = ("id", "link", "occurred_at", "ip_hash", "user_agent", "referrer")
ordering = ("-occurred_at",)
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
@admin.display(description="IP hash")
def ip_hash_short(self, obj: Click) -> str:
return (obj.ip_hash[:12] + "") if obj.ip_hash else ""
@admin.display(description="User agent")
def user_agent_short(self, obj: Click) -> str:
ua = obj.user_agent or ""
return (ua[:48] + "") if len(ua) > 48 else (ua or "")
+156
View File
@@ -0,0 +1,156 @@
"""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))
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class LinksConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "links"
+64
View File
@@ -0,0 +1,64 @@
"""Bearer token auth for /api/. The lock that keeps a public API host closed."""
from __future__ import annotations
import hmac
from collections.abc import Callable
from functools import wraps
from django.conf import settings
from django.http import HttpRequest, JsonResponse
from django.views.decorators.csrf import csrf_exempt
def parse_bearer(request: HttpRequest) -> str | None:
header = request.META.get("HTTP_AUTHORIZATION") or ""
if not header.startswith("Bearer "):
return None
token = header[7:].strip()
return token or None
def authenticate_token(raw_token: str | None) -> str | None:
"""Return the configured token name, or None if no match.
Accepts either ``name:secret`` (as callers send) or the bare secret.
Always compares against every configured token (constant-time).
"""
configured = list(getattr(settings, "SHORTENER_API_TOKENS", []) or [])
if not configured or not raw_token:
return None
matched_name: str | None = None
for name, secret in configured:
full = f"{name}:{secret}"
if hmac.compare_digest(raw_token, full) or hmac.compare_digest(raw_token, secret):
matched_name = name
# Keep looping so compare_digest runs for every token.
return matched_name
def token_name_for_request(request: HttpRequest) -> str | None:
return authenticate_token(parse_bearer(request))
def require_bearer(view: Callable) -> Callable:
"""Decorator: 503 if no tokens configured, 401 if missing/wrong Bearer."""
@csrf_exempt
@wraps(view)
def wrapper(request, *args, **kwargs):
configured = list(getattr(settings, "SHORTENER_API_TOKENS", []) or [])
if not configured:
return JsonResponse({"detail": "Service unavailable"}, status=503)
name = token_name_for_request(request)
if not name:
response = JsonResponse({"detail": "Unauthorized"}, status=401)
response["WWW-Authenticate"] = "Bearer"
return response
request.token_name = name
return view(request, *args, **kwargs)
return wrapper
+41
View File
@@ -0,0 +1,41 @@
"""DEBUG-only form to mint a short link without curling the API."""
from django.conf import settings
from django.contrib import messages
from django.http import Http404
from django.shortcuts import render
from django.views.decorators.http import require_http_methods
from links.forms import DebugCreateForm
from links.services import CodeCollisionError, create_link
@require_http_methods(["GET", "POST"])
def debug_create(request):
if not settings.DEBUG:
raise Http404()
form = DebugCreateForm(request.POST or None)
created_link = None
if request.method == "POST" and form.is_valid():
try:
created_link, minted = create_link(
target_url=form.cleaned_data["target_url"],
title=form.cleaned_data.get("title") or "",
external_ref=form.cleaned_data.get("external_ref") or "",
expires_at=None,
token_name="debug",
)
except CodeCollisionError:
form.add_error(None, "Could not allocate a unique code.")
else:
if minted:
messages.success(request, "Short link created.")
else:
messages.info(request, "Existing active link returned (same URL + ref).")
return render(
request,
"links/debug_create.html",
{"form": form, "created_link": created_link},
)
+21
View File
@@ -0,0 +1,21 @@
from django import forms
from links.services import ValidationError, validate_target_url
class DebugCreateForm(forms.Form):
target_url = forms.URLField(
label="Target URL",
widget=forms.URLInput(
attrs={"placeholder": "https://mkdrealtor.com/listings/oak-st", "autofocus": True}
),
)
title = forms.CharField(label="Title", required=False, max_length=200)
external_ref = forms.CharField(label="External ref", required=False, max_length=64)
def clean_target_url(self) -> str:
raw = self.cleaned_data["target_url"]
try:
return validate_target_url(raw)
except ValidationError as exc:
raise forms.ValidationError(str(exc)) from exc
+68
View File
@@ -0,0 +1,68 @@
"""Keep the short domain and Django admin off the public API hostname."""
from __future__ import annotations
from django.conf import settings
from django.http import Http404, HttpRequest
def _normalize_host(host: str) -> str:
return host.split(":")[0].lower().rstrip(".")
def _host_in(host: str, configured: list[str]) -> bool:
needle = _normalize_host(host)
raw = host.lower()
for entry in configured:
if not entry:
continue
if raw == entry.lower() or needle == _normalize_host(entry):
return True
return False
def is_api_host(host: str) -> bool:
return _host_in(host, list(getattr(settings, "SHORT_API_HOSTS", []) or []))
def is_public_host(host: str) -> bool:
return _host_in(host, list(getattr(settings, "SHORT_PUBLIC_HOSTS", []) or []))
def is_admin_host(host: str) -> bool:
return _host_in(host, list(getattr(settings, "SHORT_ADMIN_HOSTS", []) or []))
class HostSplitMiddleware:
"""Short host = redirects only. API host = /api/ (Bearer). Admin = local only.
A public DNS name may be listed in SHORT_API_HOSTS. Auth, not the network,
keeps /api/ closed: missing/wrong Bearer is 401; empty token list is 503.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request: HttpRequest):
path = request.path
if path in {"/healthz", "/healthz/"}:
return self.get_response(request)
host = request.get_host()
if path.startswith("/debug"):
if not settings.DEBUG or is_public_host(host):
raise Http404()
return self.get_response(request)
if path.startswith("/admin"):
if not is_admin_host(host):
raise Http404()
return self.get_response(request)
if path.startswith("/api/"):
# Short redirect hostname never serves the API, even if mis-listed.
if is_public_host(host) or not is_api_host(host):
raise Http404()
return self.get_response(request)
+49
View File
@@ -0,0 +1,49 @@
# Generated by Django 6.1 on 2026-08-30 10:09
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ShortLink',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(db_index=True, max_length=8, unique=True)),
('target_url', models.URLField(max_length=2048)),
('title', models.CharField(blank=True, max_length=200)),
('created_by_token', models.CharField(max_length=64)),
('external_ref', models.CharField(blank=True, db_index=True, max_length=64)),
('is_active', models.BooleanField(default=True)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('click_count', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Click',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('occurred_at', models.DateTimeField(auto_now_add=True)),
('ip_hash', models.CharField(blank=True, max_length=64)),
('user_agent', models.CharField(blank=True, max_length=512)),
('referrer', models.CharField(blank=True, max_length=1024)),
('link', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clicks', to='links.shortlink')),
],
options={
'ordering': ['-occurred_at'],
},
),
]
View File
+53
View File
@@ -0,0 +1,53 @@
import uuid
from django.db import models
from django.utils import timezone
class ShortLink(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
code = models.CharField(max_length=8, unique=True, db_index=True)
target_url = models.URLField(max_length=2048)
title = models.CharField(max_length=200, blank=True)
created_by_token = models.CharField(max_length=64)
external_ref = models.CharField(max_length=64, blank=True, db_index=True)
is_active = models.BooleanField(default=True)
expires_at = models.DateTimeField(null=True, blank=True)
click_count = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return self.code
def is_available(self) -> bool:
if not self.is_active:
return False
if self.expires_at is not None and timezone.now() >= self.expires_at:
return False
return True
@property
def public_short_url(self) -> str:
from django.conf import settings
origin = (getattr(settings, "PUBLIC_SHORT_URL", "") or "").rstrip("/")
return f"{origin}/{self.code}"
class Click(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
link = models.ForeignKey(ShortLink, on_delete=models.CASCADE, related_name="clicks")
occurred_at = models.DateTimeField(auto_now_add=True)
ip_hash = models.CharField(max_length=64, blank=True)
user_agent = models.CharField(max_length=512, blank=True)
referrer = models.CharField(max_length=1024, blank=True)
class Meta:
ordering = ["-occurred_at"]
def __str__(self) -> str:
return f"{self.link.code} @ {self.occurred_at}"
+179
View File
@@ -0,0 +1,179 @@
"""Link minting, target-URL allowlist, click recording."""
from __future__ import annotations
import hashlib
import hmac
import logging
import secrets
from datetime import datetime
from urllib.parse import urlsplit, urlunsplit
from django.conf import settings
from django.db import IntegrityError
from django.db.models import F
from django.http import HttpRequest
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from links.models import Click, ShortLink
logger = logging.getLogger(__name__)
MAX_CODE_ATTEMPTS = 8
class ValidationError(ValueError):
pass
class CodeCollisionError(RuntimeError):
pass
def host_allowed(hostname: str, allowed: list[str]) -> bool:
hostname = hostname.lower().rstrip(".")
for entry in allowed:
entry = entry.lower().strip()
if entry.startswith("*."):
entry = entry[2:]
entry = entry.lstrip(".").rstrip(".")
if not entry:
continue
if hostname == entry or hostname.endswith("." + entry):
return True
return False
def validate_target_url(raw: str) -> str:
"""Return a canonical https URL or raise ValidationError."""
if not raw or not isinstance(raw, str):
raise ValidationError("invalid url")
raw = raw.strip()
if raw.startswith("//"):
raise ValidationError("invalid url")
try:
parts = urlsplit(raw)
except ValueError as exc:
raise ValidationError("invalid url") from exc
if parts.scheme.lower() != "https":
raise ValidationError("invalid url")
if parts.username or parts.password:
raise ValidationError("invalid url")
hostname = (parts.hostname or "").lower().rstrip(".")
if not hostname:
raise ValidationError("invalid url")
allowed = list(getattr(settings, "SHORT_ALLOWED_HOSTS", []) or [])
if not host_allowed(hostname, allowed):
raise ValidationError("host not allowlisted")
netloc = hostname
if parts.port:
netloc = f"{hostname}:{parts.port}"
return urlunsplit(("https", netloc, parts.path, parts.query, parts.fragment))
def generate_code(length: int | None = None) -> str:
alphabet = settings.CODE_ALPHABET
size = length if length is not None else settings.SHORT_CODE_LENGTH
return "".join(secrets.choice(alphabet) for _ in range(size))
def mint_unique_code() -> str:
for _ in range(MAX_CODE_ATTEMPTS):
code = generate_code()
if not ShortLink.objects.filter(code=code).exists():
return code
raise CodeCollisionError("could not allocate a unique code")
def parse_expires_at(value) -> datetime | None:
if value in (None, ""):
return None
if not isinstance(value, str):
raise ValidationError("invalid expires_at")
parsed = parse_datetime(value)
if parsed is None:
raise ValidationError("invalid expires_at")
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
return parsed
def find_idempotent_link(target_url: str, external_ref: str) -> ShortLink | None:
if not external_ref:
return None
qs = ShortLink.objects.filter(
target_url=target_url,
external_ref=external_ref,
is_active=True,
)
now = timezone.now()
for link in qs:
if link.expires_at is None or link.expires_at > now:
return link
return None
def create_link(
*,
target_url: str,
title: str,
external_ref: str,
expires_at: datetime | None,
token_name: str,
) -> tuple[ShortLink, bool]:
"""Return ``(link, created)``. ``created`` is False on idempotent hit."""
existing = find_idempotent_link(target_url, external_ref)
if existing:
return existing, False
for _ in range(MAX_CODE_ATTEMPTS):
try:
link = ShortLink.objects.create(
code=generate_code(),
target_url=target_url,
title=title,
external_ref=external_ref,
expires_at=expires_at,
created_by_token=token_name,
)
return link, True
except IntegrityError:
continue
raise CodeCollisionError("could not allocate a unique code")
def client_ip(request: HttpRequest) -> str:
forwarded = request.META.get("HTTP_X_FORWARDED_FOR") or ""
if forwarded:
return forwarded.split(",")[0].strip()
return (request.META.get("REMOTE_ADDR") or "").strip()
def hash_ip(ip: str) -> str:
pepper = getattr(settings, "CLICK_IP_PEPPER", "") or ""
if not ip or not pepper:
return ""
return hmac.new(pepper.encode(), ip.encode(), hashlib.sha256).hexdigest()
def record_click(request: HttpRequest, link: ShortLink) -> None:
Click.objects.create(
link=link,
ip_hash=hash_ip(client_ip(request)),
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:512],
referrer=(request.META.get("HTTP_REFERER") or "")[:1024],
)
ShortLink.objects.filter(pk=link.pk).update(click_count=F("click_count") + 1)
def record_click_best_effort(request: HttpRequest, link: ShortLink) -> None:
try:
record_click(request, link)
except Exception:
logger.exception("click record failed for code=%s", link.code)
@@ -0,0 +1,35 @@
{% extends "core/base.html" %}
{% block title %}Create short link · debug{% endblock %}
{% block content %}
<p class="eyebrow">Debug only</p>
<h1>Create a short link</h1>
<p class="lede">Uses the same allowlist as the API. Not served when <code>DEBUG</code> is false.</p>
{% if messages %}
<ul class="messages">
{% for message in messages %}<li>{{ message }}</li>{% endfor %}
</ul>
{% endif %}
{% if created_link %}
<div class="result">
<p>Short URL</p>
<p><code>{{ created_link.public_short_url }}</code></p>
<p>Target: {{ created_link.target_url }}</p>
<a class="btn" href="{{ created_link.public_short_url }}">Open</a>
</div>
{% endif %}
<form method="post" action="{% url 'debug-create' %}">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<label>
{{ field.label }}
{{ field }}
{% if field.errors %}<span class="error">{{ field.errors|striptags }}</span>{% endif %}
</label>
{% endfor %}
<button type="submit">Mint</button>
</form>
{% endblock %}
+500
View File
@@ -0,0 +1,500 @@
"""Tests required by Implementation.md §12."""
import json
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.utils import timezone
from django.conf import settings
from links.models import Click, ShortLink
User = get_user_model()
AUTH = "Bearer monica:dev-only-token"
TOKENS = [("monica", "dev-only-token")]
SETTINGS = dict(
SHORTENER_API_TOKENS=TOKENS,
PUBLIC_SHORT_URL="https://go.mkdrealtor.com",
SHORT_PUBLIC_HOSTS=["go.mkdrealtor.com"],
SHORT_API_HOSTS=["testserver", "localhost", "127.0.0.1", "shortener.example.com"],
SHORT_ADMIN_HOSTS=["localhost", "127.0.0.1"],
SHORT_ALLOWED_HOSTS=["mkdrealtor.com"],
CLICK_IP_PEPPER="test-pepper-not-the-secret-key",
ALLOWED_HOSTS=[
"testserver",
"localhost",
"127.0.0.1",
"go.mkdrealtor.com",
"shortener.example.com",
],
SHORT_CODE_LENGTH=6,
)
def _json(response):
return json.loads(response.content.decode())
@override_settings(**SETTINGS)
class AuthTests(TestCase):
def test_missing_bearer_401(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response["WWW-Authenticate"], "Bearer")
def test_wrong_token_401(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer monica:wrong-secret",
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response["WWW-Authenticate"], "Bearer")
self.assertEqual(_json(response)["detail"], "Unauthorized")
def test_matching_named_token_201(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
)
self.assertEqual(response.status_code, 201)
body = _json(response)
self.assertEqual(body["target_url"], "https://mkdrealtor.com/x")
self.assertTrue(body["is_active"])
self.assertEqual(body["click_count"], 0)
@override_settings(SHORTENER_API_TOKENS=[])
def test_empty_tokens_503(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
)
self.assertEqual(response.status_code, 503)
@override_settings(**SETTINGS)
class HostSplitTests(TestCase):
def test_public_host_api_404_even_with_bearer(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
HTTP_HOST="go.mkdrealtor.com",
)
self.assertEqual(response.status_code, 404)
@override_settings(
SHORT_API_HOSTS=[
"testserver",
"localhost",
"127.0.0.1",
"shortener.example.com",
"go.mkdrealtor.com",
]
)
def test_short_host_never_serves_api_even_if_also_listed_as_api(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
HTTP_HOST="go.mkdrealtor.com",
)
self.assertEqual(response.status_code, 404)
def test_public_host_admin_404(self):
response = self.client.get("/admin/", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
def test_public_api_host_without_bearer_401(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_HOST="shortener.example.com",
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response["WWW-Authenticate"], "Bearer")
def test_public_api_host_wrong_token_401(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer monica:wrong-secret",
HTTP_HOST="shortener.example.com",
)
self.assertEqual(response.status_code, 401)
def test_public_api_host_valid_bearer_201(self):
response = self.client.post(
"/api/links/",
data=json.dumps({"target_url": "https://mkdrealtor.com/x"}),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
HTTP_HOST="shortener.example.com",
)
self.assertEqual(response.status_code, 201)
def test_public_api_host_admin_404(self):
response = self.client.get("/admin/", HTTP_HOST="shortener.example.com")
self.assertEqual(response.status_code, 404)
def test_healthz_on_public_and_api(self):
for host in ("go.mkdrealtor.com", "testserver", "shortener.example.com"):
response = self.client.get("/healthz/", HTTP_HOST=host)
self.assertEqual(response.status_code, 200)
self.assertEqual(_json(response), {"status": "ok"})
@override_settings(**SETTINGS)
class AllowlistTests(TestCase):
def _post(self, target_url):
return self.client.post(
"/api/links/",
data=json.dumps({"target_url": target_url}),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
)
def test_https_allowlisted_ok(self):
self.assertEqual(self._post("https://mkdrealtor.com/x").status_code, 201)
self.assertEqual(self._post("https://www.mkdrealtor.com/x").status_code, 201)
@override_settings(SHORT_ALLOWED_HOSTS=["mkdrealtor.com", "*.aimloperations.com"])
def test_glob_suffix_and_apex(self):
self.assertEqual(
self._post("https://aimloperations.com/web_design").status_code, 201
)
self.assertEqual(
self._post("https://www.aimloperations.com/web_design").status_code, 201
)
def test_http_rejected(self):
self.assertEqual(self._post("http://mkdrealtor.com/x").status_code, 400)
def test_evil_host_rejected(self):
self.assertEqual(self._post("https://evil.com").status_code, 400)
def test_javascript_rejected(self):
self.assertEqual(self._post("javascript:alert(1)").status_code, 400)
def test_protocol_relative_rejected(self):
self.assertEqual(self._post("//evil.com").status_code, 400)
def test_credentials_rejected(self):
self.assertEqual(
self._post("https://user:pass@mkdrealtor.com/x").status_code, 400
)
def test_suffix_does_not_match_cousin_domain(self):
self.assertEqual(self._post("https://notmkdrealtor.com/x").status_code, 400)
@override_settings(**SETTINGS)
class RedirectTests(TestCase):
def setUp(self):
self.link = ShortLink.objects.create(
code="a3k9xm",
target_url="https://mkdrealtor.com/listings/oak-st",
created_by_token="monica",
)
def test_active_code_302_to_target(self):
response = self.client.get(
"/a3k9xm", HTTP_HOST="go.mkdrealtor.com", follow=False
)
self.assertEqual(response.status_code, 302)
self.assertNotEqual(response.status_code, 301)
self.assertEqual(
response["Location"], "https://mkdrealtor.com/listings/oak-st"
)
def test_head_also_302_without_click(self):
response = self.client.head(
"/a3k9xm", HTTP_HOST="go.mkdrealtor.com", follow=False
)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response["Location"], "https://mkdrealtor.com/listings/oak-st"
)
self.link.refresh_from_db()
self.assertEqual(self.link.click_count, 0)
self.assertEqual(Click.objects.filter(link=self.link).count(), 0)
def test_inactive_404(self):
self.link.is_active = False
self.link.save()
response = self.client.get("/a3k9xm", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
def test_expired_404(self):
self.link.expires_at = timezone.now() - timedelta(minutes=1)
self.link.save()
response = self.client.get("/a3k9xm", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
def test_unknown_404(self):
response = self.client.get("/zzzzzz", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
def test_invalid_code_shape_404(self):
response = self.client.get("/AB", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
def test_public_root_is_landing(self):
response = self.client.get("/", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "URL Shortening Service")
self.assertContains(response, "aimloperations.com")
self.assertNotContains(response, "Create a short link")
def test_click_row_and_count(self):
self.client.get(
"/a3k9xm",
HTTP_HOST="go.mkdrealtor.com",
HTTP_USER_AGENT="sms-client",
)
self.link.refresh_from_db()
self.assertEqual(self.link.click_count, 1)
self.assertEqual(Click.objects.filter(link=self.link).count(), 1)
click = Click.objects.get(link=self.link)
self.assertEqual(click.user_agent, "sms-client")
self.assertTrue(click.ip_hash)
self.assertNotIn("127.0.0.1", click.ip_hash)
@override_settings(**SETTINGS)
class CreateTests(TestCase):
def _post(self, payload, **headers):
return self.client.post(
"/api/links/",
data=json.dumps(payload),
content_type="application/json",
HTTP_AUTHORIZATION=AUTH,
**headers,
)
def test_short_url_uses_public_origin_not_api_host(self):
response = self._post(
{"target_url": "https://mkdrealtor.com/x"},
HTTP_HOST="testserver",
)
self.assertEqual(response.status_code, 201)
body = _json(response)
self.assertTrue(body["short_url"].startswith("https://go.mkdrealtor.com/"))
self.assertNotIn("testserver", body["short_url"])
def test_idempotent_same_target_and_external_ref(self):
payload = {
"target_url": "https://mkdrealtor.com/listings/oak-st?utm_source=monica",
"title": "Oak St",
"external_ref": "campaign-1",
}
first = self._post(payload)
second = self._post(payload)
self.assertEqual(first.status_code, 201)
self.assertEqual(second.status_code, 200)
self.assertEqual(_json(first)["code"], _json(second)["code"])
self.assertEqual(ShortLink.objects.count(), 1)
def test_empty_external_ref_always_mints(self):
payload = {"target_url": "https://mkdrealtor.com/x"}
first = self._post(payload)
second = self._post(payload)
self.assertEqual(first.status_code, 201)
self.assertEqual(second.status_code, 201)
self.assertNotEqual(_json(first)["code"], _json(second)["code"])
def test_code_uses_unambiguous_alphabet(self):
alphabet = set(settings.CODE_ALPHABET)
forbidden = set("01iloILO")
for _ in range(20):
response = self._post({"target_url": "https://mkdrealtor.com/x"})
code = _json(response)["code"]
self.assertTrue(set(code) <= alphabet)
self.assertFalse(set(code) & forbidden)
self.assertEqual(len(code), 6)
def test_stores_token_name_not_secret(self):
self._post({"target_url": "https://mkdrealtor.com/x"})
link = ShortLink.objects.get()
self.assertEqual(link.created_by_token, "monica")
self.assertNotIn("dev-only-token", link.created_by_token)
def test_disable_is_idempotent(self):
code = _json(self._post({"target_url": "https://mkdrealtor.com/x"}))["code"]
first = self.client.post(
f"/api/links/{code}/disable/",
HTTP_AUTHORIZATION=AUTH,
)
second = self.client.post(
f"/api/links/{code}/disable/",
HTTP_AUTHORIZATION=AUTH,
)
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertFalse(_json(second)["is_active"])
self.client.get(f"/{code}", HTTP_HOST="go.mkdrealtor.com")
# disabled → no redirect
response = self.client.get(f"/{code}", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
@override_settings(**SETTINGS)
class UseCaseTests(TestCase):
"""monica_site mints via Bearer; a phone hits the public short URL and 302s."""
def test_caller_creates_then_public_redirects(self):
create = self.client.post(
"/api/links/",
data=json.dumps(
{
"target_url": (
"https://mkdrealtor.com/listings/oak-st"
"?utm_source=monica&utm_medium=sms"
),
"title": "Oak St listing",
"external_ref": "campaign-uuid-optional",
}
),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer monica:dev-only-token",
HTTP_HOST="testserver",
)
self.assertEqual(create.status_code, 201)
body = _json(create)
self.assertEqual(
body["short_url"], f"https://go.mkdrealtor.com/{body['code']}"
)
follow = self.client.get(
f"/{body['code']}",
HTTP_HOST="go.mkdrealtor.com",
follow=False,
)
self.assertEqual(follow.status_code, 302)
self.assertEqual(
follow["Location"],
"https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms",
)
@override_settings(**SETTINGS)
class DebugCreateTests(TestCase):
def test_hidden_when_not_debug(self):
response = self.client.get("/debug/")
self.assertEqual(response.status_code, 404)
@override_settings(DEBUG=True)
def test_form_when_debug(self):
response = self.client.get("/debug/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Create a short link")
@override_settings(DEBUG=True)
def test_hidden_on_public_short_host_even_in_debug(self):
response = self.client.get("/debug/", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
@override_settings(DEBUG=True)
def test_post_mints_link(self):
response = self.client.post(
"/debug/",
{
"target_url": "https://mkdrealtor.com/listings/oak-st",
"title": "Oak St",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(ShortLink.objects.count(), 1)
link = ShortLink.objects.get()
self.assertEqual(link.created_by_token, "debug")
self.assertContains(response, link.public_short_url)
ADMIN_SETTINGS = {**SETTINGS, "SHORT_ADMIN_HOSTS": ["testserver", "localhost"]}
@override_settings(**ADMIN_SETTINGS)
class AdminTests(TestCase):
def setUp(self):
self.user = User.objects.create_superuser("admin", "admin@example.com", "pass")
self.client.force_login(self.user)
self.link = ShortLink.objects.create(
code="a3k9xm",
target_url="https://mkdrealtor.com/listings/oak-st",
title="Oak St",
created_by_token="monica",
)
def test_changelist(self):
response = self.client.get("/admin/links/shortlink/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "a3k9xm")
self.assertContains(response, "Oak St")
def test_add_page(self):
response = self.client.get("/admin/links/shortlink/add/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Target url")
def test_add_mints_code(self):
response = self.client.post(
"/admin/links/shortlink/add/",
{
"target_url": "https://mkdrealtor.com/new",
"title": "From admin",
"is_active": "on",
"external_ref": "",
"clicks-TOTAL_FORMS": "0",
"clicks-INITIAL_FORMS": "0",
"clicks-MIN_NUM_FORMS": "0",
"clicks-MAX_NUM_FORMS": "0",
"_save": "Save",
},
)
self.assertEqual(response.status_code, 302)
created = ShortLink.objects.exclude(code="a3k9xm").get()
self.assertEqual(created.target_url, "https://mkdrealtor.com/new")
self.assertEqual(created.created_by_token, "admin")
self.assertEqual(len(created.code), 6)
def test_disable_action(self):
response = self.client.post(
"/admin/links/shortlink/",
{
"action": "disable_links",
"_selected_action": [str(self.link.pk)],
},
)
self.assertEqual(response.status_code, 302)
self.link.refresh_from_db()
self.assertFalse(self.link.is_active)
def test_click_changelist(self):
Click.objects.create(link=self.link, user_agent="sms-client")
response = self.client.get("/admin/links/click/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "a3k9xm")
def test_admin_404_on_public_hosts(self):
self.client.logout()
response = self.client.get("/admin/", HTTP_HOST="go.mkdrealtor.com")
self.assertEqual(response.status_code, 404)
response = self.client.get("/admin/", HTTP_HOST="shortener.example.com")
self.assertEqual(response.status_code, 404)
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from links import api
urlpatterns = [
path("", api.links_collection, name="links-collection"),
path("<str:code>/", api.link_detail, name="links-detail"),
path("<str:code>/disable/", api.link_disable, name="links-disable"),
]
+31
View File
@@ -0,0 +1,31 @@
"""Public redirect — no auth. SMS recipients tap GET /<code>."""
from __future__ import annotations
import re
from django.http import Http404, HttpResponseRedirect
from django.views.decorators.http import require_http_methods
from links.models import ShortLink
from links.services import record_click_best_effort
CODE_RE = re.compile(r"^[a-z0-9]{4,8}$")
@require_http_methods(["GET", "HEAD"])
def redirect_view(request, code: str):
if not CODE_RE.fullmatch(code):
raise Http404()
try:
link = ShortLink.objects.get(code=code)
except ShortLink.DoesNotExist as exc:
raise Http404() from exc
if not link.is_available():
raise Http404()
if request.method == "GET":
record_click_best_effort(request, link)
return HttpResponseRedirect(link.target_url)