Files
guilan-ace-backend/apps/content/validators.py

112 lines
4.8 KiB
Python

import re
from urllib.parse import urlsplit
from django.core.exceptions import ValidationError
ROUTES = {"home", "about", "events", "blog"}
CONTACT_KINDS = {"email", "phone", "telegram", "instagram", "website"}
TAG_PATTERN = re.compile(r"<\s*/?\s*[a-zA-Z][^>]*>|&(?:#\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);", re.I)
def plain_text(value):
if value and (TAG_PATTERN.search(value) or "\x00" in value):
raise ValidationError("HTML and character entities are not allowed in editorial text.")
def text(value, limit, *, required=False):
if not isinstance(value, str) or (required and not value.strip()) or len(value) > limit:
raise ValidationError(f"Expected {'nonempty ' if required else ''}text of at most {limit} characters.")
plain_text(value)
def _entries(value, allowed, required):
if not isinstance(value, list) or len(value) > 20:
raise ValidationError("Expected a list of at most 20 entries.")
seen_keys = set()
seen_positions = set()
for entry in value:
if not isinstance(entry, dict) or set(entry) != allowed:
raise ValidationError("Entry fields do not match the approved schema.")
key = entry.get(required)
position = entry.get("position")
if not isinstance(key, str) or key in seen_keys or type(position) is not int or position < 0 or position in seen_positions or type(entry.get("visible")) is not bool:
raise ValidationError("Entry IDs and positions must be unique and valid.")
seen_keys.add(key)
seen_positions.add(position)
text(entry["label"], 80, required=True)
def navigation(value):
_entries(value, {"route_id", "label", "position", "visible"}, "route_id")
if {entry["route_id"] for entry in value} != ROUTES:
raise ValidationError("Navigation must contain the four fixed routes.")
def contacts(value):
_entries(value, {"kind", "label", "url", "position", "visible"}, "kind")
for entry in value:
kind = entry["kind"]
url = entry["url"]
if kind not in CONTACT_KINDS or not isinstance(url, str) or len(url) > 500:
raise ValidationError("Invalid contact kind or URL.")
try:
parsed = urlsplit(url)
except ValueError as exc:
raise ValidationError("Invalid contact URL.") from exc
if kind == "email":
valid = parsed.scheme == "mailto" and bool(parsed.path) and "@" in parsed.path and not parsed.netloc
elif kind == "phone":
valid = parsed.scheme == "tel" and bool(re.fullmatch(r"\+?[0-9 -]{5,25}", parsed.path))
else:
valid = parsed.scheme == "https" and bool(parsed.netloc) and not parsed.username and not parsed.password
if not valid:
raise ValidationError("Contact URL must use the approved scheme for its kind.")
def section_entries(value):
if not isinstance(value, list) or len(value) > 20:
raise ValidationError("Expected at most 20 section entries.")
for entry in value:
if not isinstance(entry, dict) or set(entry) != {"title", "text"}:
raise ValidationError("Section entries require title and text.")
text(entry["title"], 120, required=True)
text(entry["text"], 600, required=True)
def action(value):
if not isinstance(value, dict) or set(value) != {"label", "route_id"}:
raise ValidationError("Action requires label and route_id.")
text(value["label"], 40, required=True)
if not isinstance(value["route_id"], str) or value["route_id"] not in ROUTES - {"home"}:
raise ValidationError("Action route is not approved.")
def action_url(value):
if not value:
return
if not isinstance(value, str) or value != value.strip() or "\\" in value or any(ord(char) < 32 for char in value):
raise ValidationError("Use a root-relative path or a secure https URL.")
try:
parsed = urlsplit(value)
except ValueError as exc:
raise ValidationError("Invalid action URL.") from exc
if value.startswith("/"):
if value.startswith("//") or parsed.scheme or parsed.netloc:
raise ValidationError("Internal links must be root-relative paths.")
return
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
raise ValidationError("External action URLs must use https and cannot include credentials.")
external_https_url(value)
def external_https_url(value):
try:
parsed = urlsplit(value)
hostname = parsed.hostname
parsed.port
except (AttributeError, ValueError) as exc:
raise ValidationError("Enter a valid https URL without credentials.") from exc
if parsed.scheme != "https" or not hostname or parsed.username or parsed.password:
raise ValidationError("Enter a valid https URL without credentials.")