studio-d-o commited on
Commit
e83a158
·
verified ·
1 Parent(s): ed5e789

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
- reporter_contact: Mapped[str | None] = mapped_column(String(255), default=None)
 
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
- finder_contact: Mapped[str | None] = mapped_column(String(255), default=None)
 
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
- if s.reporter_contact and "@" in s.reporter_contact:
118
- _send_sighting_confirmation(s.reporter_contact, bool(cands),
119
- make_ref("sighting", s.id))
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 : reporter_contact = VARCHAR(255) (saisie libre).
159
- _reporter = serialize.clip(reporter_email, 255)
 
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
- finder_contact=_contact, message=_message,
 
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
+ )