From 8b38ab18d4920cc9d9ac4720537d48cd69a023a4 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Wed, 16 Sep 2026 05:26:20 -0500 Subject: [PATCH] Allow LAN admin access and add a campaign mint form (#11). 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. --- .env.example | 8 +- Implementation.md | 6 +- README.md | 6 +- docker-compose.yml | 5 +- site/links/admin.py | 55 ++++++++++++- site/links/forms.py | 57 +++++++++++++- site/links/services.py | 36 ++++++++- site/links/templates/admin/index.html | 108 ++++++++++++++++++++++++++ site/links/tests.py | 80 ++++++++++++++++++- site/shortener/settings/base.py | 6 +- 10 files changed, 351 insertions(+), 16 deletions(-) create mode 100644 site/links/templates/admin/index.html diff --git a/.env.example b/.env.example index deee64c..94d5c48 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ DJANGO_ENV=dev DJANGO_DEBUG=true DJANGO_SECRET_KEY=dev-only-change-me -DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,web,url-shortener +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,web,url-shortener,10.0.0.128 # Leave empty for SQLite when running manage.py on the host. # Compose ignores this and uses the bundled Postgres via COMPOSE_DATABASE_URL. @@ -22,8 +22,10 @@ PUBLIC_SHORT_URL=http://127.0.0.1:8005 SHORT_PUBLIC_HOSTS=piha.lc # Extra Host values that also serve /api/ (localhost / docker). SHORT_API_HOSTS=localhost,127.0.0.1,0.0.0.0,web,url-shortener -# Django admin — keep local. Do not add the public short hostname. -SHORT_ADMIN_HOSTS=localhost,127.0.0.1 +# Django admin — keep off the public short hostname. LAN IP is for local compose. +SHORT_ADMIN_HOSTS=localhost,127.0.0.1,10.0.0.128 +# Compose publish address. 0.0.0.0 so another machine can hit 10.0.0.128:8005. +WEB_BIND=0.0.0.0 # Named, rotatable tokens. This is what keeps /api/ closed on a public hostname. # Generate: python -c "import secrets; print(secrets.token_urlsafe(32))" # Format: name:secret,name:secret — never reuse DJANGO_SECRET_KEY. diff --git a/Implementation.md b/Implementation.md index fe97705..92500d4 100644 --- a/Implementation.md +++ b/Implementation.md @@ -77,7 +77,7 @@ Internet - One NPM proxy host. `location /` → gunicorn. Django 404s `/admin/` and `/debug/`. - `monica_site` calls `SHORTENER_BASE_URL` (`https://piha.lc` or `https://beta.piha.li`). -- Django admin stays on `SHORT_ADMIN_HOSTS` (localhost). Not on the public host. +- Django admin stays on `SHORT_ADMIN_HOSTS` (localhost / LAN IP). Not on the public host. ### 2.4 Target allowlist @@ -96,7 +96,7 @@ Even if NPM is misconfigured, the Django process must refuse the wrong surface: - `request.get_host()` in `SHORT_PUBLIC_HOSTS` → redirects **and** `/api/` (Bearer). - `request.get_host()` in `SHORT_API_HOSTS` → `/api/` (still Bearer). Extra names (localhost, docker) only. -- `request.get_host()` in `SHORT_ADMIN_HOSTS` → `/admin/` (localhost only by default). +- `request.get_host()` in `SHORT_ADMIN_HOSTS` → `/admin/` (localhost and `10.0.0.128` by default). - `/healthz/` allowed on both. No secrets in the body. --- @@ -355,7 +355,7 @@ Reject codes that do not match `^[a-z0-9]{4,8}$` with 404 (no extra work). | `PUBLIC_SHORT_URL` | origin for minted URLs, e.g. `https://piha.lc` (no trailing slash) | | `SHORT_PUBLIC_HOSTS` | comma list; Host values that serve redirects **and** `/api/` | | `SHORT_API_HOSTS` | comma list; extra Host values that serve `/api/` (localhost / docker) | -| `SHORT_ADMIN_HOSTS` | comma list; Host values that serve `/admin/` (default localhost only) | +| `SHORT_ADMIN_HOSTS` | comma list; Host values that serve `/admin/` (default localhost + `10.0.0.128`) | | `SHORTENER_API_TOKENS` | `name:secret,name:secret` — required for API | | `SHORT_ALLOWED_HOSTS` | allowlist for `target_url` hosts | | `SHORT_CODE_LENGTH` | default `6` | diff --git a/README.md b/README.md index 61e2099..4a91e56 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ This service is standalone. Do not fold it into `monica_site`. named Bearer token. No token / wrong token → **401**. No tokens configured → **503**. `GET /` never requires a token. -`/admin/` is 404 on the public host (localhost only). `GET /debug/` is a mint form -when `DEBUG=true` and never on the public short host. +`/admin/` is 404 on the public host. Local compose serves it on `localhost` and +`10.0.0.128` (`http://10.0.0.128:8005/admin/` from another machine on the LAN). +`GET /debug/` is a mint form when `DEBUG=true` and never on the public short host. ## Local run @@ -40,6 +41,7 @@ uv run python manage.py runserver # optional local admin: # uv run python manage.py createsuperuser # then http://127.0.0.1:8005/admin/ (compose) or :8000 (runserver) +# LAN: http://10.0.0.128:8005/admin/ (compose publishes 0.0.0.0:8005) ``` Tests (SQLite, no network): diff --git a/docker-compose.yml b/docker-compose.yml index 92d966e..e997dfb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,19 +17,20 @@ services: web: build: . ports: - - "127.0.0.1:8005:8000" + - "${WEB_BIND:-0.0.0.0}:8005:8000" volumes: - ./site:/app/site environment: DJANGO_ENV: ${DJANGO_ENV:-dev} DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me} DJANGO_DEBUG: ${DJANGO_DEBUG:-true} - DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,url-shortener} + DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,url-shortener,10.0.0.128} DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://url_shortener:url_shortener@db:5432/url_shortener} SHORT_DOMAIN: ${SHORT_DOMAIN:-localhost:8005} PUBLIC_SHORT_URL: ${PUBLIC_SHORT_URL:-http://127.0.0.1:8005} SHORT_PUBLIC_HOSTS: ${SHORT_PUBLIC_HOSTS:-piha.lc} SHORT_API_HOSTS: ${SHORT_API_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,url-shortener} + SHORT_ADMIN_HOSTS: ${SHORT_ADMIN_HOSTS:-localhost,127.0.0.1,10.0.0.128} SHORTENER_API_TOKENS: ${SHORTENER_API_TOKENS:-monica:dev-only-token} SHORT_ALLOWED_HOSTS: ${SHORT_ALLOWED_HOSTS:-mkdrealtor.com,aimloperations.com} SHORT_CODE_LENGTH: ${SHORT_CODE_LENGTH:-6} diff --git a/site/links/admin.py b/site/links/admin.py index f654ec5..29501e4 100644 --- a/site/links/admin.py +++ b/site/links/admin.py @@ -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 diff --git a/site/links/forms.py b/site/links/forms.py index bf115ac..b71f424 100644 --- a/site/links/forms.py +++ b/site/links/forms.py @@ -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 diff --git a/site/links/services.py b/site/links/services.py index e19523f..b96b087 100644 --- a/site/links/services.py +++ b/site/links/services.py @@ -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): diff --git a/site/links/templates/admin/index.html b/site/links/templates/admin/index.html new file mode 100644 index 0000000..9c1f6c7 --- /dev/null +++ b/site/links/templates/admin/index.html @@ -0,0 +1,108 @@ +{% extends "admin/base_site.html" %} +{% load i18n static admin_filters %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block extrahead %} +{{ block.super }} + +{% endblock %} + +{% block coltype %}colMS{% endblock %} + +{% block bodyclass %}{{ block.super }} dashboard{% endblock %} + +{% block nav-breadcrumbs %}{% endblock %} + +{% block nav-sidebar %}{% endblock %} + +{% block content %} +
+
+

Create a short link

+ {% if created_link %} +
+ + + +

Target: {{ created_link.target_url }}

+
+ {% endif %} +
+ {% csrf_token %} + {{ quick_mint_form.non_field_errors }} + + {% for host in allowed_domains %} + {% for field in quick_mint_form %} +
+ {{ field.errors }} + + {{ field }} + {% if field.help_text %}

{{ field.help_text }}

{% endif %} +
+ {% endfor %} +
+ +
+
+
+ {% include "admin/app_list.html" with app_list=app_list show_changelinks=True %} +
+{% endblock %} + +{% block sidebar %} + +{% endblock %} diff --git a/site/links/tests.py b/site/links/tests.py index cda0fe3..ec3d483 100644 --- a/site/links/tests.py +++ b/site/links/tests.py @@ -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", + ) diff --git a/site/shortener/settings/base.py b/site/shortener/settings/base.py index 7706080..1c166be 100644 --- a/site/shortener/settings/base.py +++ b/site/shortener/settings/base.py @@ -89,7 +89,7 @@ DEBUG = env_bool("DJANGO_DEBUG", False) allowed_hosts = env_list( "DJANGO_ALLOWED_HOSTS", - "localhost,127.0.0.1,0.0.0.0,testserver,web,url-shortener,piha.lc,beta.piha.li", + "localhost,127.0.0.1,0.0.0.0,testserver,web,url-shortener,10.0.0.128,piha.lc,beta.piha.li", ) ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"] @@ -185,7 +185,9 @@ SHORT_API_HOSTS = env_list( "localhost,127.0.0.1,0.0.0.0,testserver,web,url-shortener", ) # Django admin — local/dev only. Never put the public short hostname here. -SHORT_ADMIN_HOSTS = env_list("SHORT_ADMIN_HOSTS", "localhost,127.0.0.1") +SHORT_ADMIN_HOSTS = env_list( + "SHORT_ADMIN_HOSTS", "localhost,127.0.0.1,10.0.0.128" +) SHORTENER_API_TOKENS = parse_api_tokens(env("SHORTENER_API_TOKENS", "") or "") SHORT_ALLOWED_HOSTS = env_list( "SHORT_ALLOWED_HOSTS", "mkdrealtor.com,aimloperations.com" -- 2.54.0