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.
214 lines
6.4 KiB
Python
214 lines
6.4 KiB
Python
"""Link minting, target-URL allowlist, click recording."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
import secrets
|
|
from datetime import datetime
|
|
from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
|
|
from django.conf import settings
|
|
from django.db import IntegrityError
|
|
from django.db.models import F
|
|
from django.http import HttpRequest
|
|
from django.utils import timezone
|
|
from django.utils.dateparse import parse_datetime
|
|
|
|
from links.models import Click, ShortLink
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_CODE_ATTEMPTS = 8
|
|
|
|
|
|
class ValidationError(ValueError):
|
|
pass
|
|
|
|
|
|
class CodeCollisionError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def host_allowed(hostname: str, allowed: list[str]) -> bool:
|
|
hostname = hostname.lower().rstrip(".")
|
|
for entry in allowed:
|
|
entry = entry.lower().strip()
|
|
if entry.startswith("*."):
|
|
entry = entry[2:]
|
|
entry = entry.lstrip(".").rstrip(".")
|
|
if not entry:
|
|
continue
|
|
if hostname == entry or hostname.endswith("." + entry):
|
|
return True
|
|
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):
|
|
raise ValidationError("invalid url")
|
|
raw = raw.strip()
|
|
if raw.startswith("//"):
|
|
raise ValidationError("invalid url")
|
|
|
|
try:
|
|
parts = urlsplit(raw)
|
|
except ValueError as exc:
|
|
raise ValidationError("invalid url") from exc
|
|
|
|
if parts.scheme.lower() != "https":
|
|
raise ValidationError("invalid url")
|
|
if parts.username or parts.password:
|
|
raise ValidationError("invalid url")
|
|
|
|
hostname = (parts.hostname or "").lower().rstrip(".")
|
|
if not hostname:
|
|
raise ValidationError("invalid url")
|
|
|
|
allowed = list(getattr(settings, "SHORT_ALLOWED_HOSTS", []) or [])
|
|
if not host_allowed(hostname, allowed):
|
|
raise ValidationError("host not allowlisted")
|
|
|
|
netloc = hostname
|
|
if parts.port:
|
|
netloc = f"{hostname}:{parts.port}"
|
|
return urlunsplit(("https", netloc, parts.path, parts.query, parts.fragment))
|
|
|
|
|
|
def generate_code(length: int | None = None) -> str:
|
|
alphabet = settings.CODE_ALPHABET
|
|
size = length if length is not None else settings.SHORT_CODE_LENGTH
|
|
return "".join(secrets.choice(alphabet) for _ in range(size))
|
|
|
|
|
|
def mint_unique_code() -> str:
|
|
for _ in range(MAX_CODE_ATTEMPTS):
|
|
code = generate_code()
|
|
if not ShortLink.objects.filter(code=code).exists():
|
|
return code
|
|
raise CodeCollisionError("could not allocate a unique code")
|
|
|
|
|
|
def parse_expires_at(value) -> datetime | None:
|
|
if value in (None, ""):
|
|
return None
|
|
if not isinstance(value, str):
|
|
raise ValidationError("invalid expires_at")
|
|
parsed = parse_datetime(value)
|
|
if parsed is None:
|
|
raise ValidationError("invalid expires_at")
|
|
if timezone.is_naive(parsed):
|
|
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
|
|
return parsed
|
|
|
|
|
|
def find_idempotent_link(target_url: str, external_ref: str) -> ShortLink | None:
|
|
if not external_ref:
|
|
return None
|
|
qs = ShortLink.objects.filter(
|
|
target_url=target_url,
|
|
external_ref=external_ref,
|
|
is_active=True,
|
|
)
|
|
now = timezone.now()
|
|
for link in qs:
|
|
if link.expires_at is None or link.expires_at > now:
|
|
return link
|
|
return None
|
|
|
|
|
|
def create_link(
|
|
*,
|
|
target_url: str,
|
|
title: str,
|
|
external_ref: str,
|
|
expires_at: datetime | None,
|
|
token_name: str,
|
|
) -> tuple[ShortLink, bool]:
|
|
"""Return ``(link, created)``. ``created`` is False on idempotent hit."""
|
|
existing = find_idempotent_link(target_url, external_ref)
|
|
if existing:
|
|
return existing, False
|
|
|
|
for _ in range(MAX_CODE_ATTEMPTS):
|
|
try:
|
|
link = ShortLink.objects.create(
|
|
code=generate_code(),
|
|
target_url=target_url,
|
|
title=title,
|
|
external_ref=external_ref,
|
|
expires_at=expires_at,
|
|
created_by_token=token_name,
|
|
)
|
|
return link, True
|
|
except IntegrityError:
|
|
continue
|
|
raise CodeCollisionError("could not allocate a unique code")
|
|
|
|
|
|
def client_ip(request: HttpRequest) -> str:
|
|
forwarded = request.META.get("HTTP_X_FORWARDED_FOR") or ""
|
|
if forwarded:
|
|
return forwarded.split(",")[0].strip()
|
|
return (request.META.get("REMOTE_ADDR") or "").strip()
|
|
|
|
|
|
def hash_ip(ip: str) -> str:
|
|
pepper = getattr(settings, "CLICK_IP_PEPPER", "") or ""
|
|
if not ip or not pepper:
|
|
return ""
|
|
return hmac.new(pepper.encode(), ip.encode(), hashlib.sha256).hexdigest()
|
|
|
|
|
|
def record_click(request: HttpRequest, link: ShortLink) -> None:
|
|
Click.objects.create(
|
|
link=link,
|
|
ip_hash=hash_ip(client_ip(request)),
|
|
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:512],
|
|
referrer=(request.META.get("HTTP_REFERER") or "")[:1024],
|
|
)
|
|
ShortLink.objects.filter(pk=link.pk).update(click_count=F("click_count") + 1)
|
|
|
|
|
|
def record_click_best_effort(request: HttpRequest, link: ShortLink) -> None:
|
|
try:
|
|
record_click(request, link)
|
|
except Exception:
|
|
logger.exception("click record failed for code=%s", link.code)
|