Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files
backend/app/models.py
CHANGED
|
@@ -85,7 +85,8 @@ class Sighting(Base):
|
|
| 85 |
lng: Mapped[float | None] = mapped_column(Float, default=None)
|
| 86 |
spotted_at: Mapped[dt.datetime | None] = mapped_column(DateTime, default=None)
|
| 87 |
|
| 88 |
-
|
|
|
|
| 89 |
|
| 90 |
# open | linked | dismissed
|
| 91 |
status: Mapped[str] = mapped_column(String(20), default="open")
|
|
@@ -190,7 +191,8 @@ class ContactLead(Base):
|
|
| 190 |
sighting_id: Mapped[int | None] = mapped_column(
|
| 191 |
ForeignKey("sightings.id"), default=None
|
| 192 |
)
|
| 193 |
-
|
|
|
|
| 194 |
message: Mapped[str | None] = mapped_column(Text, default=None)
|
| 195 |
confidence: Mapped[float | None] = mapped_column(Float, default=None)
|
| 196 |
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now)
|
|
|
|
| 85 |
lng: Mapped[float | None] = mapped_column(Float, default=None)
|
| 86 |
spotted_at: Mapped[dt.datetime | None] = mapped_column(DateTime, default=None)
|
| 87 |
|
| 88 |
+
# Chiffré au repos (PII du trouveur) → Text : le blob « enc1:… » dépasse 255 car.
|
| 89 |
+
reporter_contact: Mapped[str | None] = mapped_column(Text, default=None)
|
| 90 |
|
| 91 |
# open | linked | dismissed
|
| 92 |
status: Mapped[str] = mapped_column(String(20), default="open")
|
|
|
|
| 191 |
sighting_id: Mapped[int | None] = mapped_column(
|
| 192 |
ForeignKey("sightings.id"), default=None
|
| 193 |
)
|
| 194 |
+
# Chiffré au repos (PII du trouveur) → Text : le blob « enc1:… » dépasse 255 car.
|
| 195 |
+
finder_contact: Mapped[str | None] = mapped_column(Text, default=None)
|
| 196 |
message: Mapped[str | None] = mapped_column(Text, default=None)
|
| 197 |
confidence: Mapped[float | None] = mapped_column(Float, default=None)
|
| 198 |
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now)
|
backend/app/routers/admin.py
CHANGED
|
@@ -225,7 +225,7 @@ def overview(db: Session = Depends(get_db)):
|
|
| 225 |
"photos": [storage.photo_url(p) for p in c.photos],
|
| 226 |
"leads": [
|
| 227 |
{"ref": make_ref("lead", le.id),
|
| 228 |
-
"contact": le.finder_contact, "message": le.message,
|
| 229 |
"confidence": le.confidence, "created_at": str(le.created_at)}
|
| 230 |
for le in c.leads
|
| 231 |
],
|
|
@@ -293,7 +293,7 @@ def lookup_event(ref: str, db: Session = Depends(get_db)):
|
|
| 293 |
"name": c.name, "status": c.status, "email": c.email, "phone": crypto.decrypt_str(c.phone),
|
| 294 |
"description": c.description, "created_at": str(c.created_at),
|
| 295 |
"photos": [storage.photo_url(p) for p in c.photos],
|
| 296 |
-
"leads": [{"ref": make_ref("lead", le.id), "contact": le.finder_contact,
|
| 297 |
"message": le.message, "created_at": str(le.created_at)}
|
| 298 |
for le in c.leads],
|
| 299 |
}
|
|
@@ -303,7 +303,7 @@ def lookup_event(ref: str, db: Session = Depends(get_db)):
|
|
| 303 |
raise HTTPException(404, "Événement introuvable")
|
| 304 |
return {
|
| 305 |
"ref": make_ref("sighting", s.id), "type": "sighting", "id": s.id,
|
| 306 |
-
"status": s.status, "reporter_contact": s.reporter_contact,
|
| 307 |
"lat": s.lat, "lng": s.lng, "matched_cat_id": s.matched_cat_id,
|
| 308 |
"confidence": s.matched_confidence, "created_at": str(s.created_at),
|
| 309 |
"photos": [storage.photo_url(p) for p in s.photos],
|
|
@@ -317,7 +317,7 @@ def lookup_event(ref: str, db: Session = Depends(get_db)):
|
|
| 317 |
"cat_id": le.cat_id, "cat_ref": make_ref("cat", le.cat_id) if le.cat_id else None,
|
| 318 |
"sighting_id": le.sighting_id,
|
| 319 |
"sighting_ref": make_ref("sighting", le.sighting_id) if le.sighting_id else None,
|
| 320 |
-
"finder_contact": le.finder_contact, "message": le.message,
|
| 321 |
"confidence": le.confidence, "created_at": str(le.created_at),
|
| 322 |
"photos": [storage.photo_url(p) for p in (le.sighting.photos if le.sighting else [])],
|
| 323 |
}
|
|
|
|
| 225 |
"photos": [storage.photo_url(p) for p in c.photos],
|
| 226 |
"leads": [
|
| 227 |
{"ref": make_ref("lead", le.id),
|
| 228 |
+
"contact": crypto.decrypt_str(le.finder_contact), "message": le.message,
|
| 229 |
"confidence": le.confidence, "created_at": str(le.created_at)}
|
| 230 |
for le in c.leads
|
| 231 |
],
|
|
|
|
| 293 |
"name": c.name, "status": c.status, "email": c.email, "phone": crypto.decrypt_str(c.phone),
|
| 294 |
"description": c.description, "created_at": str(c.created_at),
|
| 295 |
"photos": [storage.photo_url(p) for p in c.photos],
|
| 296 |
+
"leads": [{"ref": make_ref("lead", le.id), "contact": crypto.decrypt_str(le.finder_contact),
|
| 297 |
"message": le.message, "created_at": str(le.created_at)}
|
| 298 |
for le in c.leads],
|
| 299 |
}
|
|
|
|
| 303 |
raise HTTPException(404, "Événement introuvable")
|
| 304 |
return {
|
| 305 |
"ref": make_ref("sighting", s.id), "type": "sighting", "id": s.id,
|
| 306 |
+
"status": s.status, "reporter_contact": crypto.decrypt_str(s.reporter_contact),
|
| 307 |
"lat": s.lat, "lng": s.lng, "matched_cat_id": s.matched_cat_id,
|
| 308 |
"confidence": s.matched_confidence, "created_at": str(s.created_at),
|
| 309 |
"photos": [storage.photo_url(p) for p in s.photos],
|
|
|
|
| 317 |
"cat_id": le.cat_id, "cat_ref": make_ref("cat", le.cat_id) if le.cat_id else None,
|
| 318 |
"sighting_id": le.sighting_id,
|
| 319 |
"sighting_ref": make_ref("sighting", le.sighting_id) if le.sighting_id else None,
|
| 320 |
+
"finder_contact": crypto.decrypt_str(le.finder_contact), "message": le.message,
|
| 321 |
"confidence": le.confidence, "created_at": str(le.created_at),
|
| 322 |
"photos": [storage.photo_url(p) for p in (le.sighting.photos if le.sighting else [])],
|
| 323 |
}
|
backend/app/routers/sightings.py
CHANGED
|
@@ -17,7 +17,7 @@ from fastapi import (APIRouter, BackgroundTasks, Depends, File, Form, Header,
|
|
| 17 |
from sqlalchemy import select
|
| 18 |
from sqlalchemy.orm import Session
|
| 19 |
|
| 20 |
-
from .. import config, mailer, matching, models, ratelimit, schemas, serialize, storage
|
| 21 |
from ..database import SessionLocal, get_db
|
| 22 |
from ..geo import approx_coord
|
| 23 |
from ..refs import make_ref
|
|
@@ -114,9 +114,9 @@ def _analyze_sighting_bg(sighting_id: int, radius_km: float | None = None) -> No
|
|
| 114 |
s.matched_confidence = float(cands[0]["similarity"])
|
| 115 |
db.commit()
|
| 116 |
# Accusé au trouveur (privé) une fois l'analyse faite → reflète le vrai résultat.
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
except Exception:
|
| 121 |
db.rollback()
|
| 122 |
# État terminal de repli (0 candidat) pour débloquer le sondage.
|
|
@@ -155,8 +155,9 @@ def create_sighting(
|
|
| 155 |
storage.guard_count(photos)
|
| 156 |
real = [f for f in photos if f and f.filename]
|
| 157 |
ratelimit.enforce_daily_image_quota(request, len(real)) # quota 50/jour/IP
|
| 158 |
-
# Clamp anti-500 Postgres
|
| 159 |
-
|
|
|
|
| 160 |
sighting = models.Sighting(
|
| 161 |
description=serialize.clip_description(description), lat=approx_coord(lat), lng=approx_coord(lng),
|
| 162 |
reporter_contact=_reporter,
|
|
@@ -294,10 +295,13 @@ def leave_lead(
|
|
| 294 |
))
|
| 295 |
_contact = (payload.finder_contact or "").strip() or None
|
| 296 |
_message = (payload.message or "").strip() or None
|
|
|
|
|
|
|
|
|
|
| 297 |
dup = next(
|
| 298 |
(le for le in existing
|
| 299 |
if le.cat_id == cat.id
|
| 300 |
-
and (le.finder_contact or None) == _contact
|
| 301 |
and (le.message or None) == _message),
|
| 302 |
None,
|
| 303 |
)
|
|
@@ -315,7 +319,8 @@ def leave_lead(
|
|
| 315 |
|
| 316 |
lead = models.ContactLead(
|
| 317 |
cat_id=cat.id, sighting_id=s.id,
|
| 318 |
-
|
|
|
|
| 319 |
confidence=s.matched_confidence,
|
| 320 |
)
|
| 321 |
db.add(lead)
|
|
|
|
| 17 |
from sqlalchemy import select
|
| 18 |
from sqlalchemy.orm import Session
|
| 19 |
|
| 20 |
+
from .. import config, crypto, mailer, matching, models, ratelimit, schemas, serialize, storage
|
| 21 |
from ..database import SessionLocal, get_db
|
| 22 |
from ..geo import approx_coord
|
| 23 |
from ..refs import make_ref
|
|
|
|
| 114 |
s.matched_confidence = float(cands[0]["similarity"])
|
| 115 |
db.commit()
|
| 116 |
# Accusé au trouveur (privé) une fois l'analyse faite → reflète le vrai résultat.
|
| 117 |
+
_rep = crypto.decrypt_str(s.reporter_contact) # chiffré au repos
|
| 118 |
+
if _rep and "@" in _rep:
|
| 119 |
+
_send_sighting_confirmation(_rep, bool(cands), make_ref("sighting", s.id))
|
| 120 |
except Exception:
|
| 121 |
db.rollback()
|
| 122 |
# État terminal de repli (0 candidat) pour débloquer le sondage.
|
|
|
|
| 155 |
storage.guard_count(photos)
|
| 156 |
real = [f for f in photos if f and f.filename]
|
| 157 |
ratelimit.enforce_daily_image_quota(request, len(real)) # quota 50/jour/IP
|
| 158 |
+
# Clamp anti-500 Postgres, puis CHIFFREMENT au repos : le courriel du trouveur
|
| 159 |
+
# est de la PII de tiers — un dump de la base ne doit pas l'exposer.
|
| 160 |
+
_reporter = crypto.encrypt_str(serialize.clip(reporter_email, 255))
|
| 161 |
sighting = models.Sighting(
|
| 162 |
description=serialize.clip_description(description), lat=approx_coord(lat), lng=approx_coord(lng),
|
| 163 |
reporter_contact=_reporter,
|
|
|
|
| 295 |
))
|
| 296 |
_contact = (payload.finder_contact or "").strip() or None
|
| 297 |
_message = (payload.message or "").strip() or None
|
| 298 |
+
# Déduplication : le contact est CHIFFRÉ au repos et Fernet n'est pas
|
| 299 |
+
# déterministe (même texte → chiffré différent). On compare donc les valeurs
|
| 300 |
+
# DÉCHIFFRÉES, pas les blobs.
|
| 301 |
dup = next(
|
| 302 |
(le for le in existing
|
| 303 |
if le.cat_id == cat.id
|
| 304 |
+
and (crypto.decrypt_str(le.finder_contact) or None) == _contact
|
| 305 |
and (le.message or None) == _message),
|
| 306 |
None,
|
| 307 |
)
|
|
|
|
| 319 |
|
| 320 |
lead = models.ContactLead(
|
| 321 |
cat_id=cat.id, sighting_id=s.id,
|
| 322 |
+
# PII de tiers → chiffrée au repos (déchiffrée à l'affichage propriétaire/admin).
|
| 323 |
+
finder_contact=crypto.encrypt_str(_contact), message=_message,
|
| 324 |
confidence=s.matched_confidence,
|
| 325 |
)
|
| 326 |
db.add(lead)
|
backend/app/serialize.py
CHANGED
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
| 10 |
import json
|
| 11 |
import re
|
| 12 |
|
| 13 |
-
from . import models, schemas, storage
|
| 14 |
|
| 15 |
# La description est saisie librement par le propriétaire et MONTRÉE au trouveur.
|
| 16 |
# La promesse de confidentialité = le trouveur ne voit jamais le contact du
|
|
@@ -142,7 +142,7 @@ def sighting_out(s: models.Sighting) -> schemas.SightingOut:
|
|
| 142 |
lat=s.lat,
|
| 143 |
lng=s.lng,
|
| 144 |
spotted_at=s.spotted_at,
|
| 145 |
-
reporter_contact=s.reporter_contact,
|
| 146 |
status=s.status,
|
| 147 |
created_at=s.created_at,
|
| 148 |
photos=[photo_out(p) for p in s.photos],
|
|
@@ -199,7 +199,7 @@ def lead_out(le: models.ContactLead) -> schemas.LeadOut:
|
|
| 199 |
photo_url = storage.photo_url(s.photos[0])
|
| 200 |
return schemas.LeadOut(
|
| 201 |
id=le.id,
|
| 202 |
-
finder_contact=le.finder_contact,
|
| 203 |
message=le.message,
|
| 204 |
confidence=le.confidence,
|
| 205 |
created_at=le.created_at,
|
|
|
|
| 10 |
import json
|
| 11 |
import re
|
| 12 |
|
| 13 |
+
from . import crypto, models, schemas, storage
|
| 14 |
|
| 15 |
# La description est saisie librement par le propriétaire et MONTRÉE au trouveur.
|
| 16 |
# La promesse de confidentialité = le trouveur ne voit jamais le contact du
|
|
|
|
| 142 |
lat=s.lat,
|
| 143 |
lng=s.lng,
|
| 144 |
spotted_at=s.spotted_at,
|
| 145 |
+
reporter_contact=crypto.decrypt_str(s.reporter_contact),
|
| 146 |
status=s.status,
|
| 147 |
created_at=s.created_at,
|
| 148 |
photos=[photo_out(p) for p in s.photos],
|
|
|
|
| 199 |
photo_url = storage.photo_url(s.photos[0])
|
| 200 |
return schemas.LeadOut(
|
| 201 |
id=le.id,
|
| 202 |
+
finder_contact=crypto.decrypt_str(le.finder_contact),
|
| 203 |
message=le.message,
|
| 204 |
confidence=le.confidence,
|
| 205 |
created_at=le.created_at,
|
backend/migrations/versions/d5a1c8b73e90_widen_contacts_text.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""widen reporter_contact / finder_contact to Text (contacts chiffrés — fix 500 Postgres)
|
| 2 |
+
|
| 3 |
+
Revision ID: d5a1c8b73e90
|
| 4 |
+
Revises: c9a4e7f21b56
|
| 5 |
+
Create Date: 2026-07-26 00:00:00.000000
|
| 6 |
+
|
| 7 |
+
Les coordonnées du TROUVEUR (courriel d'accusé de réception et contact laissé au
|
| 8 |
+
propriétaire) sont de la PII de tiers : elles sont désormais chiffrées au repos
|
| 9 |
+
(Fernet → « enc1:… », 125 à 425 caractères selon la saisie). Les colonnes étaient
|
| 10 |
+
restées en VARCHAR(255) : sur Postgres (Neon), tout INSERT aurait levé
|
| 11 |
+
`StringDataRightTruncation` → 500. Même classe de bug que cats.phone
|
| 12 |
+
(migration e7d9c1f0a2b3). On élargit les deux colonnes en Text.
|
| 13 |
+
"""
|
| 14 |
+
from typing import Sequence, Union
|
| 15 |
+
|
| 16 |
+
import sqlalchemy as sa
|
| 17 |
+
from alembic import op
|
| 18 |
+
|
| 19 |
+
# revision identifiers, used by Alembic.
|
| 20 |
+
revision: str = 'd5a1c8b73e90'
|
| 21 |
+
down_revision: Union[str, Sequence[str], None] = 'c9a4e7f21b56'
|
| 22 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 23 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def upgrade() -> None:
|
| 27 |
+
with op.batch_alter_table('sightings', schema=None) as batch_op:
|
| 28 |
+
batch_op.alter_column(
|
| 29 |
+
'reporter_contact',
|
| 30 |
+
existing_type=sa.String(length=255),
|
| 31 |
+
type_=sa.Text(),
|
| 32 |
+
existing_nullable=True,
|
| 33 |
+
)
|
| 34 |
+
with op.batch_alter_table('contact_leads', schema=None) as batch_op:
|
| 35 |
+
batch_op.alter_column(
|
| 36 |
+
'finder_contact',
|
| 37 |
+
existing_type=sa.String(length=255),
|
| 38 |
+
type_=sa.Text(),
|
| 39 |
+
existing_nullable=True,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def downgrade() -> None:
|
| 44 |
+
with op.batch_alter_table('sightings', schema=None) as batch_op:
|
| 45 |
+
batch_op.alter_column(
|
| 46 |
+
'reporter_contact',
|
| 47 |
+
existing_type=sa.Text(),
|
| 48 |
+
type_=sa.String(length=255),
|
| 49 |
+
existing_nullable=True,
|
| 50 |
+
)
|
| 51 |
+
with op.batch_alter_table('contact_leads', schema=None) as batch_op:
|
| 52 |
+
batch_op.alter_column(
|
| 53 |
+
'finder_contact',
|
| 54 |
+
existing_type=sa.Text(),
|
| 55 |
+
type_=sa.String(length=255),
|
| 56 |
+
existing_nullable=True,
|
| 57 |
+
)
|