diff --git a/src/porchlight/validation.py b/src/porchlight/validation.py index 512ad02..ebee9d6 100644 --- a/src/porchlight/validation.py +++ b/src/porchlight/validation.py @@ -1,8 +1,10 @@ +import re from typing import Annotated from urllib.parse import urlparse -from pydantic import BaseModel, EmailStr, Field, field_validator +from pydantic import BaseModel, EmailStr, Field, ValidationError, field_validator, model_validator from pydantic_extra_types.phone_numbers import PhoneNumberValidator +from zxcvbn import zxcvbn E164Phone = Annotated[str, PhoneNumberValidator(number_format="E164")] @@ -40,3 +42,131 @@ class ProfileUpdate(BaseModel): if parsed.scheme not in ("http", "https") or not parsed.netloc: raise ValueError("Picture URL must be a valid HTTP or HTTPS URL") return v + + @field_validator("locale", mode="before") + @classmethod + def validate_locale(cls, v: str) -> str: + if isinstance(v, str): + v = v.strip() + if v == "": + return "" + if not re.match(r"^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$", v): + raise ValueError("Locale must be a valid BCP 47 language tag (e.g. en, sv-SE, zh-Hans-CN)") + return v + + +class UsernameInput(BaseModel): + username: str = Field(max_length=255) + + @field_validator("username", mode="before") + @classmethod + def validate_username(cls, v: str) -> str: + if isinstance(v, str): + v = v.strip() + if not v: + raise ValueError("Username is required") + if not re.match(r"^[a-zA-Z0-9_.@-]+$", v): + raise ValueError("Username may only contain letters, digits, dots, hyphens, underscores, and @") + return v + + +class GroupListInput(BaseModel): + groups: str = "" + + @property + def group_list(self) -> list[str]: + """Parse comma-separated groups into a deduplicated list.""" + seen: set[str] = set() + result: list[str] = [] + for g in (g.strip() for g in self.groups.split(",") if g.strip()): + if g not in seen: + seen.add(g) + result.append(g) + return result + + @field_validator("groups", mode="before") + @classmethod + def validate_groups(cls, v: str) -> str: + if isinstance(v, str): + names = [g.strip() for g in v.split(",") if g.strip()] + for name in names: + if not re.match(r"^[a-z0-9_-]{1,64}$", name): + raise ValueError( + f"Invalid group name '{name}'. " + "Groups must be 1-64 lowercase letters, digits, hyphens, or underscores." + ) + return v + + +class PasswordSet(BaseModel): + password: str = Field(min_length=8, max_length=256) + confirm: str + + @model_validator(mode="after") + def validate_password(self) -> "PasswordSet": + if self.password != self.confirm: + raise ValueError("Passwords do not match") + result = zxcvbn(self.password) + if result["score"] < 2: + feedback = result.get("feedback", {}) + warning = feedback.get("warning", "") + suggestions = feedback.get("suggestions", []) + msg = "Password is too easily guessed." + if warning: + msg += f" {warning}." + if suggestions: + msg += " " + " ".join(suggestions) + raise ValueError(msg) + return self + + +class PasswordChange(PasswordSet): + current_password: str + + @field_validator("current_password", mode="before") + @classmethod + def validate_current_password(cls, v: str) -> str: + if isinstance(v, str) and v.strip() == "": + raise ValueError("Current password is required") + return v + + +FIELD_LABELS: dict[str, str] = { + "given_name": "Given name", + "family_name": "Family name", + "preferred_username": "Display name", + "email": "Email", + "phone_number": "Phone number", + "picture": "Picture URL", + "locale": "Locale", + "username": "Username", + "groups": "Groups", + "password": "Password", + "confirm": "Confirm password", + "current_password": "Current password", +} + + +def format_validation_errors(exc: ValidationError) -> str: + """Format Pydantic ValidationError into user-friendly HTML.""" + messages: list[str] = [] + for error in exc.errors(): + field = str(error["loc"][-1]) if error["loc"] else "input" + label = FIELD_LABELS.get(field, field) + msg = error["msg"] + if error["type"] == "value_error": + raw = msg.removeprefix("Value error, ") + # If the message already starts with the label, don't duplicate it + if raw.startswith(label): + display_msg = raw + else: + display_msg = f"{label}: {raw}" + else: + display_msg = f"{label}: {msg}" + messages.append(display_msg) + + if len(messages) == 1: + return f'