"""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 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 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=s.reporter_contact, status=s.status, created_at=s.created_at, photos=[photo_out(p) for p in s.photos], ) def _candidate_token(sighting_id: int, 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.""" import hashlib import hmac from . import config key = (config.PHOTO_SIGN_KEY or "dev").encode() return hmac.new(key, f"candidate:{sighting_id}:{cat_id}".encode(), hashlib.sha256).hexdigest()[:24] def candidate_out(cat: models.Cat, confidence: float, sighting_id: int | None = None) -> schemas.CandidateOut: """Chat candidat montré au trouveur : photos + nom/desc + confiance, SANS contact ni position du propriétaire. `sighting_id` est requis pour générer le token signé ; si absent (tests legacy), on utilise l'id du chat (moins sécurisé mais fonctionnel).""" token = _candidate_token(sighting_id or 0, 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=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, )