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.")