Kyle Pope 337b50c7ce Fix QA review findings: source_id, N+1 queries, event bubbling, type mismatches
Critical fixes:
- C-01: Add receiver_umbral_name/receiver_preferred_name to frontend ConnectionRequest type
- C-02: Flush connection request before notification to populate source_id
- C-03: Add umbral_name to ProfileResponse/UserProfile, use in Settings Social card
- C-04: Remove dead code in sharing-overrides endpoint, merge instead of replace

Warning fixes:
- W-01/W-02: Batch-fetch settings in incoming/outgoing/list connection endpoints (N+1 fix)
- W-04: Add _purge_resolved_requests job for rejected/cancelled requests (30-day retention)
- W-10: Add e.stopPropagation() to notification mark-read and delete buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:29:04 +08:00

207 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import re
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, field_validator
# Shared email format regex — used by RegisterRequest, ProfileUpdate, and admin.CreateUserRequest
_EMAIL_REGEX = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
def _validate_email_format(v: str | None, *, required: bool = False) -> str | None:
"""Shared email validation. Returns normalised email or None."""
if v is None:
if required:
raise ValueError("Email is required")
return None
v = v.strip().lower()
if not v:
if required:
raise ValueError("Email is required")
return None
if not _EMAIL_REGEX.match(v):
raise ValueError("Invalid email format")
return v
def _validate_name_field(v: str | None) -> str | None:
"""Shared name field validation (strips, rejects control chars)."""
if v is None:
return None
v = v.strip()
if not v:
return None
if re.search(r"[\x00-\x1f]", v):
raise ValueError("Name must not contain control characters")
return v
def _validate_password_strength(v: str) -> str:
"""
Shared password validation (OWASP ASVS v4 Level 1).
- Minimum 12 chars (OWASP minimum)
- Maximum 128 chars (prevents DoS via large input to argon2)
- Must contain at least one letter and one non-letter
- No complexity rules per NIST SP 800-63B
"""
if len(v) < 12:
raise ValueError("Password must be at least 12 characters")
if len(v) > 128:
raise ValueError("Password must be 128 characters or fewer")
if not re.search(r"[A-Za-z]", v):
raise ValueError("Password must contain at least one letter")
if not re.search(r"[^A-Za-z]", v):
raise ValueError("Password must contain at least one non-letter character")
return v
def _validate_username(v: str) -> str:
"""Shared username validation."""
v = v.strip().lower()
if not 3 <= len(v) <= 50:
raise ValueError("Username must be 350 characters")
if not re.fullmatch(r"[a-z0-9_.\-]+", v):
raise ValueError("Username may only contain letters, numbers, _ . and -")
return v
class SetupRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str
password: str
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
return _validate_username(v)
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
return _validate_password_strength(v)
class RegisterRequest(BaseModel):
"""
Public registration schema — SEC-01: extra="forbid" prevents role injection.
An attacker sending {"username": "...", "password": "...", "role": "admin"}
will get a 422 Validation Error instead of silent acceptance.
"""
model_config = ConfigDict(extra="forbid")
username: str
password: str
email: str = Field(..., max_length=254)
date_of_birth: date
preferred_name: str | None = Field(None, max_length=100)
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
return _validate_username(v)
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
return _validate_password_strength(v)
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
result = _validate_email_format(v, required=True)
assert result is not None # required=True guarantees non-None
return result
@field_validator("date_of_birth")
@classmethod
def validate_date_of_birth(cls, v: date) -> date:
if v > date.today():
raise ValueError("Date of birth cannot be in the future")
if v.year < 1900:
raise ValueError("Date of birth is not valid")
return v
@field_validator("preferred_name")
@classmethod
def validate_preferred_name(cls, v: str | None) -> str | None:
return _validate_name_field(v)
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str
password: str
@field_validator("username")
@classmethod
def normalize_username(cls, v: str) -> str:
"""Normalise to lowercase so 'Admin' and 'admin' resolve to the same user."""
return v.strip().lower()
class ChangePasswordRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
old_password: str
new_password: str
@field_validator("new_password")
@classmethod
def validate_new_password(cls, v: str) -> str:
return _validate_password_strength(v)
class VerifyPasswordRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str
@field_validator("password")
@classmethod
def validate_length(cls, v: str) -> str:
if len(v) > 128:
raise ValueError("Password must be 128 characters or fewer")
return v
class ProfileUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
email: str | None = Field(None, max_length=254)
date_of_birth: date | None = None
@field_validator("email")
@classmethod
def validate_email(cls, v: str | None) -> str | None:
return _validate_email_format(v)
@field_validator("date_of_birth")
@classmethod
def validate_date_of_birth(cls, v: date | None) -> date | None:
if v is None:
return v
if v > date.today():
raise ValueError("Date of birth cannot be in the future")
if v.year < 1900:
raise ValueError("Date of birth is not valid")
return v
@field_validator("first_name", "last_name")
@classmethod
def validate_name_fields(cls, v: str | None) -> str | None:
return _validate_name_field(v)
class ProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
username: str
umbral_name: str
email: str | None
first_name: str | None
last_name: str | None
date_of_birth: date | None