"""Validation utilities for user profile fields.""" import json from pathlib import Path from bech32 import bech32_decode from email_validator import EmailNotValidError, validate_email # Load validation rules from shared constants _constants_path = Path(__file__).parent.parent / "shared" / "constants.json" with open(_constants_path) as f: _constants = json.load(f) TELEGRAM_RULES = _constants["validation"]["telegram"] SIGNAL_RULES = _constants["validation"]["signal"] NPUB_RULES = _constants["validation"]["nostrNpub"] def validate_contact_email(value: str | None) -> str | None: """ Validate contact email format. Returns None if valid, error message if invalid. Empty/None values are valid (field is optional). """ if not value: return None try: validate_email(value, check_deliverability=False) return None except EmailNotValidError as e: return str(e) def validate_telegram(value: str | None) -> str | None: """ Validate Telegram handle. Must start with @ if provided, with characters after @ within max length. Returns None if valid, error message if invalid. Empty/None values are valid (field is optional). """ if not value: return None prefix = TELEGRAM_RULES["mustStartWith"] max_len = TELEGRAM_RULES["maxLengthAfterAt"] if not value.startswith(prefix): return f"Telegram handle must start with {prefix}" handle = value[1:] if not handle: return f"Telegram handle must have at least one character after {prefix}" if len(handle) > max_len: return f"Telegram handle must be at most {max_len} characters (after {prefix})" return None def validate_signal(value: str | None) -> str | None: """ Validate Signal username. Any non-empty string within max length is valid. Returns None if valid, error message if invalid. Empty/None values are valid (field is optional). """ if not value: return None max_len = SIGNAL_RULES["maxLength"] # Signal usernames are fairly permissive, just check it's not empty if len(value.strip()) == 0: return "Signal username cannot be empty" if len(value) > max_len: return f"Signal username must be at most {max_len} characters" return None def validate_nostr_npub(value: str | None) -> str | None: """ Validate Nostr npub (public key in bech32 format). Must be valid bech32 with 'npub' prefix. Returns None if valid, error message if invalid. Empty/None values are valid (field is optional). """ if not value: return None prefix = NPUB_RULES["prefix"] expected_words = NPUB_RULES["bech32Words"] if not value.startswith(prefix): return f"Nostr npub must start with '{prefix}'" # Decode bech32 to validate checksum hrp, data = bech32_decode(value) if hrp is None or data is None: return "Invalid Nostr npub: bech32 checksum failed" if hrp != "npub": return "Nostr npub must have 'npub' prefix" # npub should decode to 32 bytes (256 bits) for a public key # In bech32, each character encodes 5 bits, so 32 bytes = 52 characters of data if len(data) != expected_words: return "Invalid Nostr npub: incorrect length" return None def validate_profile_fields( contact_email: str | None = None, telegram: str | None = None, signal: str | None = None, nostr_npub: str | None = None, ) -> dict[str, str]: """ Validate all profile fields at once. Returns a dict of field_name -> error_message for any invalid fields. Empty dict means all fields are valid. """ errors: dict[str, str] = {} if err := validate_contact_email(contact_email): errors["contact_email"] = err if err := validate_telegram(telegram): errors["telegram"] = err if err := validate_signal(signal): errors["signal"] = err if err := validate_nostr_npub(nostr_npub): errors["nostr_npub"] = err return errors