LAN admin on 10.0.0.128 plus quick campaign mint form (#12)
Deploy Beta / unit-tests (push) Successful in 4s
Deploy Beta / docker (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 53s

Closes #11.

## Summary
- Serve Django admin on LAN IP `10.0.0.128` (not `piha.li`). Compose now publishes `0.0.0.0:8005` and passes `SHORT_ADMIN_HOSTS` into the container.
- Admin index has a campaign mint form: domain, campaign, source, metric. Save builds `https://{domain}/?utm_campaign=&utm_source=&utm_medium=` (metric) and shows a copyable short URL.
- Public `/admin/` on `piha.lc` / `piha.li` stays 404.

## Test plan
- [ ] `cd site && uv run python manage.py test`
- [ ] Recreate compose (`docker compose up --build`) so `WEB_BIND` / `SHORT_ADMIN_HOSTS` take effect
- [ ] From another LAN machine: `http://10.0.0.128:8005/admin/` (staff login) shows the mint form
- [ ] Save a link for an allowlisted domain, copy the short URL, confirm it 302s
- [ ] `https://piha.li/admin` still 404

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2026-09-16 03:39:04 -07:00
parent dd627b75c9
commit 69d8b3e7a3
10 changed files with 351 additions and 16 deletions
+54 -1
View File
@@ -2,8 +2,15 @@ from django.contrib import admin, messages
from django.forms import ModelForm, ValidationError as FormValidationError
from django.utils.html import format_html
from links.forms import QuickMintForm, allowlisted_domain_choices
from links.models import Click, ShortLink
from links.services import ValidationError, generate_code, validate_target_url
from links.services import (
CodeCollisionError,
ValidationError,
create_link,
generate_code,
validate_target_url,
)
admin.site.site_header = "URL shortener"
admin.site.site_title = "Shortener admin"
@@ -156,3 +163,49 @@ class ClickAdmin(admin.ModelAdmin):
def user_agent_short(self, obj: Click) -> str:
ua = obj.user_agent or ""
return (ua[:48] + "") if len(ua) > 48 else (ua or "")
def _quick_mint_context(request, extra_context=None):
extra = extra_context.copy() if extra_context else {}
extra.setdefault("quick_mint_form", QuickMintForm())
extra.setdefault("created_link", None)
extra.setdefault("allowed_domains", allowlisted_domain_choices())
return extra
def _mint_from_form(request, form: QuickMintForm):
token_name = request.user.get_username() if request.user.is_authenticated else "admin"
campaign = form.cleaned_data["campaign"].strip()
return create_link(
target_url=form.cleaned_data["target_url"],
title=campaign,
external_ref="",
expires_at=None,
token_name=token_name or "admin",
)
_orig_index = admin.site.index
def _admin_index(request, extra_context=None):
extra = _quick_mint_context(request, extra_context)
form = QuickMintForm(request.POST or None)
extra["quick_mint_form"] = form
if request.method == "POST":
if form.is_valid():
try:
link, minted = _mint_from_form(request, form)
except CodeCollisionError:
form.add_error(None, "Could not allocate a unique code.")
else:
extra["created_link"] = link
extra["quick_mint_form"] = QuickMintForm()
messages.success(
request,
"Short link created." if minted else "Existing active link returned.",
)
return _orig_index(request, extra)
admin.site.index = _admin_index
+56 -1
View File
@@ -1,6 +1,7 @@
from django import forms
from django.conf import settings
from links.services import ValidationError, validate_target_url
from links.services import ValidationError, build_tracked_url, validate_target_url
class DebugCreateForm(forms.Form):
@@ -19,3 +20,57 @@ class DebugCreateForm(forms.Form):
return validate_target_url(raw)
except ValidationError as exc:
raise forms.ValidationError(str(exc)) from exc
def allowlisted_domain_choices() -> list[str]:
hosts = list(getattr(settings, "SHORT_ALLOWED_HOSTS", []) or [])
return [host for host in hosts if host and not host.startswith("*")]
class QuickMintForm(forms.Form):
domain = forms.CharField(
label="Domain",
max_length=253,
widget=forms.TextInput(
attrs={
"placeholder": "mkdrealtor.com",
"list": "allowed-domains",
"autocomplete": "off",
}
),
)
campaign = forms.CharField(
label="Campaign",
max_length=200,
widget=forms.TextInput(attrs={"placeholder": "open-house"}),
)
source = forms.CharField(
label="Source",
max_length=200,
widget=forms.TextInput(attrs={"placeholder": "sms"}),
)
metric = forms.CharField(
label="Metric",
max_length=200,
widget=forms.TextInput(attrs={"placeholder": "listing-click"}),
help_text="Stored as utm_medium.",
)
def clean(self):
cleaned = super().clean()
domain = cleaned.get("domain")
campaign = cleaned.get("campaign")
source = cleaned.get("source")
metric = cleaned.get("metric")
if not all((domain, campaign, source, metric)):
return cleaned
try:
cleaned["target_url"] = build_tracked_url(
domain=domain,
campaign=campaign,
source=source,
metric=metric,
)
except ValidationError as exc:
self.add_error("domain", str(exc))
return cleaned
+35 -1
View File
@@ -7,7 +7,7 @@ import hmac
import logging
import secrets
from datetime import datetime
from urllib.parse import urlsplit, urlunsplit
from urllib.parse import urlencode, urlsplit, urlunsplit
from django.conf import settings
from django.db import IntegrityError
@@ -45,6 +45,40 @@ def host_allowed(hostname: str, allowed: list[str]) -> bool:
return False
def normalize_destination_host(raw: str) -> str:
"""Strip scheme/path from a domain field. Raise ValidationError if empty."""
raw = (raw or "").strip()
if not raw:
raise ValidationError("invalid url")
if raw.startswith("//"):
raise ValidationError("invalid url")
if "://" not in raw:
raw = "https://" + raw
try:
parts = urlsplit(raw)
except ValueError as exc:
raise ValidationError("invalid url") from exc
hostname = (parts.hostname or "").lower().rstrip(".")
if not hostname:
raise ValidationError("invalid url")
if parts.username or parts.password:
raise ValidationError("invalid url")
return hostname
def build_tracked_url(*, domain: str, campaign: str, source: str, metric: str) -> str:
"""Build an allowlisted https URL with UTM query params."""
hostname = normalize_destination_host(domain)
query = urlencode(
{
"utm_campaign": campaign.strip(),
"utm_source": source.strip(),
"utm_medium": metric.strip(),
}
)
return validate_target_url(urlunsplit(("https", hostname, "/", query, "")))
def validate_target_url(raw: str) -> str:
"""Return a canonical https URL or raise ValidationError."""
if not raw or not isinstance(raw, str):
+108
View File
@@ -0,0 +1,108 @@
{% extends "admin/base_site.html" %}
{% load i18n static admin_filters %}
{% block extrastyle %}{{ block.super }}<link rel="stylesheet" href="{% static "admin/css/dashboard.css" %}" {% csp_nonce_attr %}>{% endblock %}
{% block extrahead %}
{{ block.super }}
<script {% csp_nonce_attr %}>
function copyShortUrl() {
var el = document.getElementById("id_short_url_result");
var btn = document.getElementById("copy-short-url");
if (!el) return;
var done = function () {
if (btn) {
btn.textContent = "Copied";
setTimeout(function () { btn.textContent = "Copy"; }, 1500);
}
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(el.value).then(done, function () {
el.select();
document.execCommand("copy");
done();
});
} else {
el.select();
document.execCommand("copy");
done();
}
}
</script>
{% endblock %}
{% block coltype %}colMS{% endblock %}
{% block bodyclass %}{{ block.super }} dashboard{% endblock %}
{% block nav-breadcrumbs %}{% endblock %}
{% block nav-sidebar %}{% endblock %}
{% block content %}
<div id="content-main" class="app-list">
<div class="module" id="quick-mint-module">
<h2>Create a short link</h2>
{% if created_link %}
<div class="form-row" style="padding: 12px 16px 0;">
<label for="id_short_url_result">Short URL</label>
<input id="id_short_url_result" type="text" readonly value="{{ created_link.public_short_url }}" size="48" style="max-width: 28rem;">
<button type="button" class="default" id="copy-short-url" onclick="copyShortUrl()">Copy</button>
<p class="help">Target: {{ created_link.target_url }}</p>
</div>
{% endif %}
<form method="post" action="{% url 'admin:index' %}" style="padding: 8px 16px 16px;">
{% csrf_token %}
{{ quick_mint_form.non_field_errors }}
<datalist id="allowed-domains">
{% for host in allowed_domains %}<option value="{{ host }}">{% endfor %}
</datalist>
{% for field in quick_mint_form %}
<div class="form-row">
{{ field.errors }}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<p class="help">{{ field.help_text }}</p>{% endif %}
</div>
{% endfor %}
<div class="submit-row">
<input type="submit" class="default" value="Save">
</div>
</form>
</div>
{% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}
</div>
{% endblock %}
{% block sidebar %}
<div id="content-related">
<div class="module" id="recent-actions-module">
<h2>{% translate 'Recent actions' %}</h2>
<h3>{% translate 'My actions' %}</h3>
{% load log %}
{% get_admin_log 10 as admin_log for_user user %}
{% if not admin_log %}
<p>{% translate 'None available' %}</p>
{% else %}
<ul class="actionlist">
{% for entry in admin_log %}
<li class="{% if entry.is_addition %}addlink{% endif %}{% if entry.is_change %}changelink{% endif %}{% if entry.is_deletion %}deletelink{% endif %}">
<span class="visually-hidden">{% if entry.is_addition %}{% translate 'Added:' %}{% elif entry.is_change %}{% translate 'Changed:' %}{% elif entry.is_deletion %}{% translate 'Deleted:' %}{% endif %}</span>
{% if entry.is_deletion or not entry.get_admin_url %}
{{ entry.object_repr|to_object_display_value }}
{% else %}
<a href="{{ entry.get_admin_url }}">{{ entry.object_repr|to_object_display_value }}</a>
{% endif %}
<br>
{% if entry.content_type %}
<span class="mini quiet">{% filter capfirst %}{{ entry.content_type.name }}{% endfilter %}</span>
{% else %}
<span class="mini quiet">{% translate 'Unknown content' %}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% endblock %}
+79 -1
View File
@@ -426,7 +426,11 @@ class DebugCreateTests(TestCase):
self.assertContains(response, link.public_short_url)
ADMIN_SETTINGS = {**SETTINGS, "SHORT_ADMIN_HOSTS": ["testserver", "localhost"]}
ADMIN_SETTINGS = {
**SETTINGS,
"SHORT_ADMIN_HOSTS": ["testserver", "localhost", "10.0.0.128"],
"ALLOWED_HOSTS": [*SETTINGS["ALLOWED_HOSTS"], "10.0.0.128"],
}
@override_settings(**ADMIN_SETTINGS)
@@ -497,3 +501,77 @@ class AdminTests(TestCase):
self.assertEqual(response.status_code, 404)
response = self.client.get("/admin/", HTTP_HOST="shortener.example.com")
self.assertEqual(response.status_code, 404)
def test_admin_200_on_lan_ip(self):
response = self.client.get("/admin/", HTTP_HOST="10.0.0.128")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Create a short link")
self.assertContains(response, "Domain")
self.assertContains(response, "Campaign")
self.assertContains(response, "Source")
self.assertContains(response, "Metric")
def test_quick_mint_creates_tracked_url(self):
response = self.client.post(
"/admin/",
{
"domain": "mkdrealtor.com",
"campaign": "open-house",
"source": "sms",
"metric": "listing-click",
},
HTTP_HOST="10.0.0.128",
)
self.assertEqual(response.status_code, 200)
created = ShortLink.objects.exclude(code="a3k9xm").get()
self.assertEqual(
created.target_url,
"https://mkdrealtor.com/?utm_campaign=open-house&utm_source=sms&utm_medium=listing-click",
)
self.assertEqual(created.title, "open-house")
self.assertEqual(created.created_by_token, "admin")
self.assertContains(response, created.public_short_url)
self.assertContains(response, "Copy")
def test_quick_mint_rejects_unknown_host(self):
response = self.client.post(
"/admin/",
{
"domain": "evil.example",
"campaign": "spam",
"source": "sms",
"metric": "click",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(ShortLink.objects.exclude(code="a3k9xm").count(), 0)
self.assertContains(response, "host not allowlisted")
class TrackedUrlTests(TestCase):
@override_settings(**SETTINGS)
def test_build_tracked_url(self):
from links.services import build_tracked_url
url = build_tracked_url(
domain="https://mkdrealtor.com/ignored",
campaign="open house",
source="sms",
metric="listing-click",
)
self.assertEqual(
url,
"https://mkdrealtor.com/?utm_campaign=open+house&utm_source=sms&utm_medium=listing-click",
)
@override_settings(**SETTINGS)
def test_build_tracked_url_rejects_unknown_host(self):
from links.services import ValidationError, build_tracked_url
with self.assertRaises(ValidationError):
build_tracked_url(
domain="evil.example",
campaign="c",
source="s",
metric="m",
)