2025-12-19 10:12:55 +01:00
|
|
|
"""Validation utilities for user profile fields."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
from bech32 import bech32_decode
|
2025-12-21 21:54:26 +01:00
|
|
|
from email_validator import EmailNotValidError, validate_email
|
2025-12-19 10:12:55 +01:00
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
# 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"]
|
|
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
|
|
|
|
|
def validate_contact_email(value: str | None) -> str | None:
|
|
|
|
|
"""
|
|
|
|
|
Validate contact email format.
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
Returns None if valid, error message if invalid.
|
|
|
|
|
Empty/None values are valid (field is optional).
|
|
|
|
|
"""
|
|
|
|
|
if not value:
|
|
|
|
|
return None
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
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.
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
Must start with @ if provided, with characters after @ within max length.
|
2025-12-19 10:12:55 +01:00
|
|
|
Returns None if valid, error message if invalid.
|
|
|
|
|
Empty/None values are valid (field is optional).
|
|
|
|
|
"""
|
|
|
|
|
if not value:
|
|
|
|
|
return None
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
prefix = TELEGRAM_RULES["mustStartWith"]
|
|
|
|
|
max_len = TELEGRAM_RULES["maxLengthAfterAt"]
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
if not value.startswith(prefix):
|
|
|
|
|
return f"Telegram handle must start with {prefix}"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:38:15 +01:00
|
|
|
handle = value[1:]
|
|
|
|
|
if not handle:
|
2025-12-20 23:06:05 +01:00
|
|
|
return f"Telegram handle must have at least one character after {prefix}"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
if len(handle) > max_len:
|
|
|
|
|
return f"Telegram handle must be at most {max_len} characters (after {prefix})"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_signal(value: str | None) -> str | None:
|
|
|
|
|
"""
|
|
|
|
|
Validate Signal username.
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
Any non-empty string within max length is valid.
|
2025-12-19 10:12:55 +01:00
|
|
|
Returns None if valid, error message if invalid.
|
|
|
|
|
Empty/None values are valid (field is optional).
|
|
|
|
|
"""
|
|
|
|
|
if not value:
|
|
|
|
|
return None
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
max_len = SIGNAL_RULES["maxLength"]
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
# Signal usernames are fairly permissive, just check it's not empty
|
|
|
|
|
if len(value.strip()) == 0:
|
|
|
|
|
return "Signal username cannot be empty"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
if len(value) > max_len:
|
|
|
|
|
return f"Signal username must be at most {max_len} characters"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_nostr_npub(value: str | None) -> str | None:
|
|
|
|
|
"""
|
|
|
|
|
Validate Nostr npub (public key in bech32 format).
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
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
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
prefix = NPUB_RULES["prefix"]
|
|
|
|
|
expected_words = NPUB_RULES["bech32Words"]
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
if not value.startswith(prefix):
|
|
|
|
|
return f"Nostr npub must start with '{prefix}'"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
# Decode bech32 to validate checksum
|
|
|
|
|
hrp, data = bech32_decode(value)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
if hrp is None or data is None:
|
|
|
|
|
return "Invalid Nostr npub: bech32 checksum failed"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
if hrp != "npub":
|
|
|
|
|
return "Nostr npub must have 'npub' prefix"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
# 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
|
2025-12-20 23:06:05 +01:00
|
|
|
if len(data) != expected_words:
|
2025-12-19 10:12:55 +01:00
|
|
|
return "Invalid Nostr npub: incorrect length"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
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.
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
Returns a dict of field_name -> error_message for any invalid fields.
|
|
|
|
|
Empty dict means all fields are valid.
|
|
|
|
|
"""
|
|
|
|
|
errors: dict[str, str] = {}
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
if err := validate_contact_email(contact_email):
|
|
|
|
|
errors["contact_email"] = err
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
if err := validate_telegram(telegram):
|
|
|
|
|
errors["telegram"] = err
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
if err := validate_signal(signal):
|
|
|
|
|
errors["signal"] = err
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-19 10:12:55 +01:00
|
|
|
if err := validate_nostr_npub(nostr_npub):
|
|
|
|
|
errors["nostr_npub"] = err
|
|
|
|
|
|
2025-12-21 21:54:26 +01:00
|
|
|
return errors
|