Allow LAN admin access and add a campaign mint form (#11).
CI / test (pull_request) Successful in 6s
CI / test (pull_request) Successful in 6s
Serve /admin/ on 10.0.0.128 so it can be used from another machine on the network, and mint tracked short URLs from domain/campaign/source/metric.
This commit is contained in:
+54
-1
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user