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
+5 -3
View File
@@ -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
View File
@@ -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` |
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+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) 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",
)
+4 -2
View File
@@ -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"