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:
+5
-3
@@ -4,7 +4,7 @@
|
|||||||
DJANGO_ENV=dev
|
DJANGO_ENV=dev
|
||||||
DJANGO_DEBUG=true
|
DJANGO_DEBUG=true
|
||||||
DJANGO_SECRET_KEY=dev-only-change-me
|
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.
|
# Leave empty for SQLite when running manage.py on the host.
|
||||||
# Compose ignores this and uses the bundled Postgres via COMPOSE_DATABASE_URL.
|
# 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
|
SHORT_PUBLIC_HOSTS=piha.lc
|
||||||
# Extra Host values that also serve /api/ (localhost / docker).
|
# Extra Host values that also serve /api/ (localhost / docker).
|
||||||
SHORT_API_HOSTS=localhost,127.0.0.1,0.0.0.0,web,url-shortener
|
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.
|
# Django admin — keep off the public short hostname. LAN IP is for local compose.
|
||||||
SHORT_ADMIN_HOSTS=localhost,127.0.0.1
|
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.
|
# Named, rotatable tokens. This is what keeps /api/ closed on a public hostname.
|
||||||
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
# Format: name:secret,name:secret — never reuse DJANGO_SECRET_KEY.
|
# Format: name:secret,name:secret — never reuse DJANGO_SECRET_KEY.
|
||||||
|
|||||||
+3
-3
@@ -77,7 +77,7 @@ Internet
|
|||||||
|
|
||||||
- One NPM proxy host. `location /` → gunicorn. Django 404s `/admin/` and `/debug/`.
|
- 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`).
|
- `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
|
### 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_PUBLIC_HOSTS` → redirects **and** `/api/` (Bearer).
|
||||||
- `request.get_host()` in `SHORT_API_HOSTS` → `/api/` (still Bearer). Extra names
|
- `request.get_host()` in `SHORT_API_HOSTS` → `/api/` (still Bearer). Extra names
|
||||||
(localhost, docker) only.
|
(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.
|
- `/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) |
|
| `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_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_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 |
|
| `SHORTENER_API_TOKENS` | `name:secret,name:secret` — required for API |
|
||||||
| `SHORT_ALLOWED_HOSTS` | allowlist for `target_url` hosts |
|
| `SHORT_ALLOWED_HOSTS` | allowlist for `target_url` hosts |
|
||||||
| `SHORT_CODE_LENGTH` | default `6` |
|
| `SHORT_CODE_LENGTH` | default `6` |
|
||||||
|
|||||||
@@ -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**.
|
named Bearer token. No token / wrong token → **401**. No tokens configured → **503**.
|
||||||
`GET /<code>` never requires a token.
|
`GET /<code>` never requires a token.
|
||||||
|
|
||||||
`/admin/` is 404 on the public host (localhost only). `GET /debug/` is a mint form
|
`/admin/` is 404 on the public host. Local compose serves it on `localhost` and
|
||||||
when `DEBUG=true` and never on the public short host.
|
`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
|
## Local run
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ uv run python manage.py runserver
|
|||||||
# optional local admin:
|
# optional local admin:
|
||||||
# uv run python manage.py createsuperuser
|
# uv run python manage.py createsuperuser
|
||||||
# then http://127.0.0.1:8005/admin/ (compose) or :8000 (runserver)
|
# 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):
|
Tests (SQLite, no network):
|
||||||
|
|||||||
+3
-2
@@ -17,19 +17,20 @@ services:
|
|||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8005:8000"
|
- "${WEB_BIND:-0.0.0.0}:8005:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./site:/app/site
|
- ./site:/app/site
|
||||||
environment:
|
environment:
|
||||||
DJANGO_ENV: ${DJANGO_ENV:-dev}
|
DJANGO_ENV: ${DJANGO_ENV:-dev}
|
||||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
|
||||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
|
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}
|
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://url_shortener:url_shortener@db:5432/url_shortener}
|
||||||
SHORT_DOMAIN: ${SHORT_DOMAIN:-localhost:8005}
|
SHORT_DOMAIN: ${SHORT_DOMAIN:-localhost:8005}
|
||||||
PUBLIC_SHORT_URL: ${PUBLIC_SHORT_URL:-http://127.0.0.1:8005}
|
PUBLIC_SHORT_URL: ${PUBLIC_SHORT_URL:-http://127.0.0.1:8005}
|
||||||
SHORT_PUBLIC_HOSTS: ${SHORT_PUBLIC_HOSTS:-piha.lc}
|
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_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}
|
SHORTENER_API_TOKENS: ${SHORTENER_API_TOKENS:-monica:dev-only-token}
|
||||||
SHORT_ALLOWED_HOSTS: ${SHORT_ALLOWED_HOSTS:-mkdrealtor.com,aimloperations.com}
|
SHORT_ALLOWED_HOSTS: ${SHORT_ALLOWED_HOSTS:-mkdrealtor.com,aimloperations.com}
|
||||||
SHORT_CODE_LENGTH: ${SHORT_CODE_LENGTH:-6}
|
SHORT_CODE_LENGTH: ${SHORT_CODE_LENGTH:-6}
|
||||||
|
|||||||
+54
-1
@@ -2,8 +2,15 @@ from django.contrib import admin, messages
|
|||||||
from django.forms import ModelForm, ValidationError as FormValidationError
|
from django.forms import ModelForm, ValidationError as FormValidationError
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
from links.forms import QuickMintForm, allowlisted_domain_choices
|
||||||
from links.models import Click, ShortLink
|
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_header = "URL shortener"
|
||||||
admin.site.site_title = "Shortener admin"
|
admin.site.site_title = "Shortener admin"
|
||||||
@@ -156,3 +163,49 @@ class ClickAdmin(admin.ModelAdmin):
|
|||||||
def user_agent_short(self, obj: Click) -> str:
|
def user_agent_short(self, obj: Click) -> str:
|
||||||
ua = obj.user_agent or ""
|
ua = obj.user_agent or ""
|
||||||
return (ua[:48] + "…") if len(ua) > 48 else (ua 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 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):
|
class DebugCreateForm(forms.Form):
|
||||||
@@ -19,3 +20,57 @@ class DebugCreateForm(forms.Form):
|
|||||||
return validate_target_url(raw)
|
return validate_target_url(raw)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise forms.ValidationError(str(exc)) from 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 logging
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
@@ -45,6 +45,40 @@ def host_allowed(hostname: str, allowed: list[str]) -> bool:
|
|||||||
return False
|
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:
|
def validate_target_url(raw: str) -> str:
|
||||||
"""Return a canonical https URL or raise ValidationError."""
|
"""Return a canonical https URL or raise ValidationError."""
|
||||||
if not raw or not isinstance(raw, str):
|
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)
|
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)
|
@override_settings(**ADMIN_SETTINGS)
|
||||||
@@ -497,3 +501,77 @@ class AdminTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
response = self.client.get("/admin/", HTTP_HOST="shortener.example.com")
|
response = self.client.get("/admin/", HTTP_HOST="shortener.example.com")
|
||||||
self.assertEqual(response.status_code, 404)
|
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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ DEBUG = env_bool("DJANGO_DEBUG", False)
|
|||||||
|
|
||||||
allowed_hosts = env_list(
|
allowed_hosts = env_list(
|
||||||
"DJANGO_ALLOWED_HOSTS",
|
"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 ["*"]
|
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",
|
"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.
|
# 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 "")
|
SHORTENER_API_TOKENS = parse_api_tokens(env("SHORTENER_API_TOKENS", "") or "")
|
||||||
SHORT_ALLOWED_HOSTS = env_list(
|
SHORT_ALLOWED_HOSTS = env_list(
|
||||||
"SHORT_ALLOWED_HOSTS", "mkdrealtor.com,aimloperations.com"
|
"SHORT_ALLOWED_HOSTS", "mkdrealtor.com,aimloperations.com"
|
||||||
|
|||||||
Reference in New Issue
Block a user