"""Conversion modèles -> schémas de sortie (avec URL de photo + attributs JSON). On construit les schémas explicitement plutôt que via model_validate() sur l'ORM : la relation `photos` porte ses `attributes` sous forme de chaîne JSON, qu'il faut parser nous-mêmes (sinon Pydantic rejette une str là où il attend un dict). """ from __future__ import annotations import json import re from . import crypto, models, schemas, storage # La description est saisie librement par le propriétaire et MONTRÉE au trouveur. # La promesse de confidentialité = le trouveur ne voit jamais le contact du # propriétaire. On masque donc tout courriel / numéro de téléphone qui se serait # glissé dans le texte libre (ex. « contactez-moi au 514-555-1234 »). _EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b") _PHONE_RE = re.compile(r"(? str | None: """Masque courriels, téléphones, URLs, @identifiants, adresses civiques, coordonnées GPS et codes postaux d'un texte libre rendu PUBLIC (description montrée au trouveur). Le masquage par regex reste best-effort : l'avis invite aussi explicitement à ne pas écrire de coordonnées dans la description.""" if not text: return text t = _EMAIL_RE.sub("[masqué]", text) t = _URL_RE.sub("[masqué]", t) t = _GPS_RE.sub("[masqué]", t) t = _POSTAL_CA_RE.sub("[masqué]", t) t = _ADDR_FR_RE.sub("[masqué]", t) t = _ADDR_EN_RE.sub("[masqué]", t) t = _PHONE_RE.sub("[masqué]", t) t = _HANDLE_RE.sub("[masqué]", t) return t def clip(s: str | None, n: int) -> str | None: """Tronque une saisie libre à la longueur de sa colonne. Postgres applique les limites VARCHAR(n) (contrairement à SQLite) : une saisie trop longue lèverait sinon une 500 StringDataRightTruncation en prod. Helper partagé par tous les routers qui écrivent du texte libre (cats, sightings, training).""" if s is None: return None s = s.strip() return s[:n] if s else None # Audit S3 : la description est une colonne Text (SANS limite VARCHAR côté # base) — sans plafond applicatif, un attaquant peut écrire des Mo de texte # par requête (épuisement stockage). 4000 caractères suffisent largement pour # décrire un chat, tout en bornant le coût par ligne. DESCRIPTION_MAX_LEN = 4000 def clip_description(text: str | None, n: int = DESCRIPTION_MAX_LEN) -> str | None: """Plafonne une description libre (colonne Text) à `n` caractères. À appeler par TOUT router qui écrit une description (create_cat, create_sighting) — même contrat que clip() : None-safe, strip, vide → None.""" return clip(text, n) def photo_summary(p: "models.Photo | None") -> str | None: """Courte description du pelage stockée dans les attributs de la photo (ex. « chat écaille de tortue ») — affichée quand l'IA a détecté le chat.""" if not p or not p.attributes: return None try: return json.loads(p.attributes).get("summary") except (json.JSONDecodeError, AttributeError): return None def photo_out(p: models.Photo) -> schemas.PhotoOut: attrs = None if p.attributes: try: attrs = json.loads(p.attributes) except json.JSONDecodeError: attrs = None return schemas.PhotoOut( id=p.id, filename=p.filename, url=storage.photo_url(p), attributes=attrs ) def photo_out_public(p: models.Photo) -> schemas.PhotoOut: """Vue photo destinée au TROUVEUR. Audit MIT #10 : on ne renvoie PAS le dict `attributes` complet (il contient la boîte de détection, la méthode et les couleurs internes des photos du propriétaire). On ne garde qu'un résumé lisible du pelage (« chat écaille de tortue »), utile pour reconnaître.""" summary = photo_summary(p) return schemas.PhotoOut( id=p.id, filename=p.filename, url=storage.photo_url(p), attributes={"summary": summary} if summary else None, ) def cat_out(cat: models.Cat) -> schemas.CatOut: return schemas.CatOut( id=cat.id, name=cat.name, description=cat.description, color=cat.color, pattern=cat.pattern, breed=cat.breed, contact_name=cat.contact_name, contact_info=cat.contact_info, last_seen_lat=cat.last_seen_lat, last_seen_lng=cat.last_seen_lng, last_seen_at=cat.last_seen_at, status=cat.status, created_at=cat.created_at, photos=[photo_out(p) for p in cat.photos], ) def sighting_out(s: models.Sighting) -> schemas.SightingOut: return schemas.SightingOut( id=s.id, description=s.description, lat=s.lat, lng=s.lng, spotted_at=s.spotted_at, reporter_contact=crypto.decrypt_str(s.reporter_contact), status=s.status, created_at=s.created_at, photos=[photo_out(p) for p in s.photos], ) def _candidate_token(secret: str, cat_id: int) -> str: """Token HMAC signé liant un candidat à un signalement précis. C4 — Remplace le cat_id brut dans la réponse publique pour empêcher l'énumération des chats perdus par GPS bombing. S1 — `secret` = result_token OPAQUE (aléatoire) du signalement, pas son id : sans ce secret (remis au seul auteur du signalement), un candidate_token n'est ni forgeable ni rejouable hors du flux légitime.""" import hashlib import hmac from . import config key = (config.PHOTO_SIGN_KEY or "dev").encode() return hmac.new(key, f"candidate:{secret}:{cat_id}".encode(), hashlib.sha256).hexdigest()[:24] def candidate_out(cat: models.Cat, confidence: float, secret: str | None = None) -> schemas.CandidateOut: """Chat candidat montré au trouveur : photos + nom/desc + confiance, SANS contact ni position du propriétaire. `secret` = result_token du signalement, requis pour générer le token signé ; si absent (tests legacy), token dégradé, non validable au dépôt d'un lead.""" token = _candidate_token(secret or "", cat.id) return schemas.CandidateOut( candidate_token=token, name=redact_contact(cat.name), description=redact_contact(cat.description), # Audit MIT #11 — color/pattern sont aussi du texte libre montré au # trouveur : on y applique le même caviardage (un propriétaire peut # glisser « noir, me joindre au 514… » dans le champ couleur). color=redact_contact(cat.color), pattern=redact_contact(cat.pattern), confidence=confidence, # On ne montre que quelques photos (assez pour reconnaître) — pas tout # l'enrôlement : moins d'exposition, payload plus léger. Vue PUBLIQUE : # sans les attributs de détection internes (audit MIT #10). photos=[photo_out_public(p) for p in cat.photos[:3]], is_poster=getattr(cat, "source", "owner") == "poster", ) def lead_out(le: models.ContactLead) -> schemas.LeadOut: """Signalement tel que vu par le PROPRIÉTAIRE (photo + lieu du trouveur).""" s = le.sighting photo_url = None if s and s.photos: photo_url = storage.photo_url(s.photos[0]) return schemas.LeadOut( id=le.id, finder_contact=crypto.decrypt_str(le.finder_contact), message=le.message, confidence=le.confidence, created_at=le.created_at, sighting_lat=s.lat if s else None, sighting_lng=s.lng if s else None, sighting_photo_url=photo_url, )