# ----------------------------------------------------------------------------- # Fabri-Ka — Agrégateur de produits québécois # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # inscriptions.py : canal d'auto-inscription des artisans/fabricants # (« Inscrire mon atelier »). # # POST /api/inscriptions dépôt public (honeypot + limite par IP) # -> statut `pending` (quarantaine de # validation, RIEN n'est publié) # GET /api/inscriptions liste (admin, X-Admin-Token) # POST /api/inscriptions/{id}/decision approve|reject (admin) # approve : vérifie le site (pipeline verify), ajoute la fiche au # registre + DB (source `auto_inscription`, additive, sérialisée) — # connectée si un catalogue public répond, sinon fiche annuaire. # # Notification simple : chaque dépôt est journalisé dans # logs/inscriptions.log (+ visible via le GET admin). # Anti-abus : honeypot (champ `entreprise_url` caché) + 5 dépôts/24 h/IP. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from datetime import date from pathlib import Path from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field from . import db from .schema import CATEGORIES router = APIRouter(prefix="/api/inscriptions") ROOT = Path(__file__).resolve().parent.parent LOG = ROOT / "logs" / "inscriptions.log" ADMIN_TOKEN = os.environ.get("FABRIKA_ADMIN_TOKEN", "") MAX_PER_IP_24H = 5 EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[a-zA-Z]{2,}$") class InscriptionIn(BaseModel): name: str = Field(min_length=2, max_length=120) metier: str = Field(min_length=2, max_length=60) # clé de CATEGORIES description: str = Field(min_length=20, max_length=4000) address: str = Field("", max_length=300) website: str = Field("", max_length=200) email: str = Field("", max_length=200) phone: str = Field("", max_length=40) socials: list[str] = Field(default_factory=list) photos: list[str] = Field(default_factory=list) # URLs d'images # honeypot : champ invisible au frontend — un humain le laisse vide entreprise_url: str = "" def _log(line: str) -> None: LOG.parent.mkdir(parents=True, exist_ok=True) with open(LOG, "a") as f: f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {line}\n") def _client_ip(request: Request) -> str: fwd = request.headers.get("x-forwarded-for", "") return (fwd.split(",")[0].strip() if fwd else None) or \ (request.client.host if request.client else "?") @router.post("") def submit(body: InscriptionIn, request: Request): # honeypot : on répond comme si tout allait bien, sans rien stocker if body.entreprise_url.strip(): return {"status": "pending", "id": 0} if body.metier not in CATEGORIES: raise HTTPException(400, "Métier/spécialité inconnu de la taxonomie") if not body.email and not body.phone: raise HTTPException(400, "Un courriel ou un téléphone est requis " "(coordonnées vérifiables)") if body.email and not EMAIL_RE.match(body.email.strip()): raise HTTPException(400, "Courriel invalide") urls = [u for u in ([body.website] + body.socials + body.photos) if u] for u in urls: if not re.match(r"^https?://", u.strip()): raise HTTPException(400, f"URL invalide (http(s) requis) : {u[:60]}") ip = _client_ip(request) con = db.connect() try: n = con.execute("SELECT COUNT(*) FROM inscriptions WHERE ip=? AND ts>?", (ip, time.time() - 86400)).fetchone()[0] if n >= MAX_PER_IP_24H: raise HTTPException(429, "Trop de dépôts depuis cette adresse — " "réessayez demain") cur = con.execute( """INSERT INTO inscriptions (ts, ip, name, metier, description, address, website, email, phone, socials, photos, status) VALUES (?,?,?,?,?,?,?,?,?,?,?,'pending')""", (time.time(), ip, body.name.strip(), body.metier, body.description.strip(), body.address.strip(), body.website.strip(), body.email.strip(), body.phone.strip(), json.dumps(body.socials[:6], ensure_ascii=False), json.dumps(body.photos[:6], ensure_ascii=False))) con.commit() sid = cur.lastrowid finally: con.close() _log(f"NOUVEAU #{sid} «{body.name.strip()}» metier={body.metier} " f"site={body.website or '-'} ip={ip}") return {"status": "pending", "id": sid, "message": "Merci ! Votre atelier est en attente de validation " "manuelle avant publication."} def _require_admin(request: Request) -> None: tok = request.headers.get("x-admin-token", "") if not ADMIN_TOKEN or tok != ADMIN_TOKEN: raise HTTPException(401, "Jeton admin requis (X-Admin-Token)") @router.get("") def list_inscriptions(request: Request, status: str = "pending"): _require_admin(request) con = db.connect() try: rows = [dict(r) for r in con.execute( "SELECT * FROM inscriptions WHERE status=? ORDER BY ts DESC LIMIT 200", (status,))] for r in rows: r["socials"] = json.loads(r.get("socials") or "[]") r["photos"] = json.loads(r.get("photos") or "[]") return {"total": len(rows), "items": rows} finally: con.close() class DecisionIn(BaseModel): action: str # approve | reject note: str = "" @router.post("/{ins_id}/decision") def decide(ins_id: int, body: DecisionIn, request: Request): _require_admin(request) if body.action not in ("approve", "reject"): raise HTTPException(400, "action = approve | reject") con = db.connect() try: row = con.execute("SELECT * FROM inscriptions WHERE id=?", (ins_id,)).fetchone() if not row: raise HTTPException(404) if row["status"] != "pending": raise HTTPException(409, f"Déjà traité ({row['status']})") ins = dict(row) con.execute("UPDATE inscriptions SET status=?, reviewed_at=?, review_note=? " "WHERE id=?", ("approved" if body.action == "approve" else "rejected", time.time(), body.note[:300], ins_id)) con.commit() finally: con.close() if body.action == "reject": _log(f"REJET #{ins_id} «{ins['name']}» ({body.note[:80]})") return {"status": "rejected", "id": ins_id} result = _publish(ins) _log(f"APPROUVÉ #{ins_id} «{ins['name']}» -> {result}") return {"status": "approved", "id": ins_id, "publication": result} def _publish(ins: dict) -> dict: """Publication d'une inscription approuvée : fiche au registre + DB. ⚠ écrit data/stores.json — sérialisé par convention (ne pas lancer en même temps qu'une passe qui réécrit le registre).""" import sys sys.path.insert(0, str(ROOT / "scripts")) from urllib.parse import urlparse from verify import verify_domain, POSTAL_RE from aggregate import norm_domain website = (ins.get("website") or "").strip() dom = norm_domain(website) if website else None reg_path = ROOT / "data" / "stores.json" reg = json.load(open(reg_path)) if dom and any(s["id"] == dom for s in reg["stores"]): return {"outcome": "deja_au_registre", "id": dom} platform, endpoint, url, ver = "", "", website, {} if dom: ver = verify_domain(dom) if not ver.get("active"): dom = None # site injoignable -> fiche annuaire sans domaine if dom: final_dom = ver.get("final_domain") or dom fu = urlparse(ver.get("final_url") or f"https://{final_dom}") url = f"{fu.scheme}://{fu.netloc}" platform = ver.get("platform") or "" endpoint = ver.get("catalog_endpoint") or "" if platform == "wix" and not endpoint: endpoint = "/_api/wix-ecommerce-storefront-web/api" store_id = final_dom else: # pas de site vérifiable : identifiant synthétique, fiche annuaire slug = re.sub(r"[^a-z0-9]+", "-", ins["name"].lower()).strip("-")[:40] store_id = f"inscription-{ins['id']}-{slug}" postal = POSTAL_RE.search(ins.get("address") or "") store = { "id": store_id, "name": ins["name"], "url": url or "", "platform": platform, "catalog_endpoint": endpoint, "city": "", "region": "", "postal_prefix": postal.group(0).replace(" ", "")[:3] if postal else None, "phone": ins.get("phone") or None, "email": ins.get("email") or "", "origin_class": "D", "origin_confidence": 0.7, "origin_evidence": "Atelier auto-inscrit via www.fabri-ka.com, validé " f"manuellement le {date.today()} (inscription " f"#{ins['id']})", "categories": [ins["metier"]], "socials": json.loads(ins.get("socials") or "[]")[:4], "discovery_sources": ["auto_inscription"], "discovery_source_urls": [], "language": "fr", "ecommerce": bool(endpoint or (ver.get("has_cart") if ver else False)), "verification_date": str(date.today()), "status": "verified", "enabled": bool(endpoint), } reg["stores"].append(store) reg["count"] = len(reg["stores"]) reg["generated"] = str(date.today()) json.dump(reg, open(reg_path, "w"), ensure_ascii=False, indent=1) con = db.connect() try: db.upsert_store(con, store) if ins.get("email"): con.execute("UPDATE stores SET email=? WHERE id=? AND " "COALESCE(email,'')=''", (ins["email"], store_id)) con.commit() finally: con.close() return {"outcome": "connectee" if endpoint else "fiche_annuaire", "id": store_id, "platform": platform, "endpoint": endpoint}