Documentation personnelle complète
- CLAUDE.md : Instructions compactes et enrichies
- personnalités/ : Profils Alexis, Tingting, Ben, Xiaoxiao + TingtingWork.md
- couple_backlog/ : Historique conflits (16-22 octobre 2025)
- conversation_topics/ : Système suivi sujets actifs
- Projects/ : Analyses techniques et projets
- ToRemember/ : Leadership socratique, suivi conversations
- Promesses_à_tenir.md, observations_patterns.md
PowerPoint skill
- .claude/skills/pptx/ : Skill officiel Anthropic (html2pptx)
- Identité visuelle Tingting : Bordeaux + Or antique + Crème
- Exemple : personnalités/Tingting_Class73_Elegant.pptx
Organisation
- planning/, stratégie/, topics/, plan_discussion/
- .gitignore : node_modules, *.pptx (sauf personnalités/), HTML/JS temp
🎯 Repo propre : 129 fichiers essentiels, 0 dependencies
1026 lines
27 KiB
Markdown
1026 lines
27 KiB
Markdown
# LeBonCoup - Spécifications Techniques
|
|
|
|
## Architecture Générale
|
|
|
|
### Stack recommandée pour MVP
|
|
|
|
**Backend :**
|
|
- Node.js + Express (ou NestJS pour plus de structure)
|
|
- Alternative : Laravel (si préférence PHP)
|
|
|
|
**Base de données :**
|
|
- PostgreSQL 14+ avec extension PostGIS (géolocalisation)
|
|
|
|
**Frontend :**
|
|
- React ou Vue.js 3
|
|
- PWA (Progressive Web App) pour notifications
|
|
- Alternative : WordPress headless (si expertise existante)
|
|
|
|
**Paiement :**
|
|
- Stripe (Checkout ou Elements)
|
|
|
|
**Hébergement :**
|
|
- Backend : Railway, Render, DigitalOcean, ou AWS
|
|
- BDD : Managed PostgreSQL (même providers)
|
|
- Stockage médias (v2) : AWS S3 ou Cloudflare R2
|
|
|
|
**CDN :**
|
|
- Cloudflare (gratuit pour commencer)
|
|
|
|
---
|
|
|
|
## 1. Authentification et Identité
|
|
|
|
### 1.1 Système de hash d'email
|
|
|
|
**Génération du user_id :**
|
|
```javascript
|
|
// Côté serveur : salt secret
|
|
const SERVER_SALT = process.env.SECRET_SALT; // stocké en env var
|
|
|
|
// Génération
|
|
const userId = SHA256(email.toLowerCase().trim() + SERVER_SALT);
|
|
```
|
|
|
|
**Pourquoi un salt ?**
|
|
- Empêche les attaques par rainbow table
|
|
- Même avec la BDD leakée, impossible de retrouver l'email sans le salt
|
|
- Le salt reste secret côté serveur
|
|
|
|
### 1.2 Dérivation de clés pour le chiffrement
|
|
|
|
**Côté client uniquement (JavaScript) :**
|
|
|
|
```javascript
|
|
// À la connexion
|
|
async function deriveKeys(email, password) {
|
|
const emailLower = email.toLowerCase().trim();
|
|
|
|
// Pour l'authentification (envoyé au serveur)
|
|
const userId = await sha256(emailLower + SERVER_SALT);
|
|
|
|
// Pour le chiffrement E2E (reste dans le navigateur)
|
|
const encryptionKey = await crypto.subtle.deriveKey(
|
|
{
|
|
name: "PBKDF2",
|
|
salt: new TextEncoder().encode(emailLower),
|
|
iterations: 100000,
|
|
hash: "SHA-256"
|
|
},
|
|
await crypto.subtle.importKey(
|
|
"raw",
|
|
new TextEncoder().encode(password),
|
|
"PBKDF2",
|
|
false,
|
|
["deriveKey"]
|
|
),
|
|
{ name: "AES-GCM", length: 256 },
|
|
false,
|
|
["encrypt", "decrypt"]
|
|
);
|
|
|
|
// Stockée en sessionStorage (volatile)
|
|
sessionStorage.setItem('encryptionKey', encryptionKey);
|
|
|
|
return { userId, encryptionKey };
|
|
}
|
|
```
|
|
|
|
**Clés dérivées :**
|
|
- `userId` : identifiant pour le serveur (hash SHA256)
|
|
- `encryptionKey` : clé AES-256 pour chiffrer/déchiffrer les messages
|
|
- La `encryptionKey` **ne quitte JAMAIS** le navigateur
|
|
|
|
### 1.3 Authentification
|
|
|
|
**Inscription :**
|
|
```javascript
|
|
POST /api/auth/signup
|
|
Body: {
|
|
userId: "8f7a9b2c...", // SHA256(email + salt)
|
|
passwordHash: "bcrypt..." // password hashé côté client
|
|
}
|
|
|
|
Serveur :
|
|
1. Vérifie que userId n'existe pas
|
|
2. Hash à nouveau le passwordHash (double hash)
|
|
3. Stocke dans users table
|
|
4. Renvoie JWT
|
|
```
|
|
|
|
**Login :**
|
|
```javascript
|
|
POST /api/auth/login
|
|
Body: {
|
|
userId: "8f7a9b2c...",
|
|
password: "plaintext" // ou passwordHash si préférence
|
|
}
|
|
|
|
Serveur :
|
|
1. Trouve user par userId
|
|
2. Vérifie password avec bcrypt.compare()
|
|
3. Génère JWT (expiré 7 jours)
|
|
4. Renvoie token
|
|
```
|
|
|
|
**JWT Payload :**
|
|
```json
|
|
{
|
|
"userId": "8f7a9b2c...",
|
|
"type": "pro" | "demandeur",
|
|
"iat": 1234567890,
|
|
"exp": 1234999999
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Base de Données
|
|
|
|
### 2.1 Schéma PostgreSQL
|
|
|
|
```sql
|
|
-- Users (pros et demandeurs)
|
|
CREATE TABLE users (
|
|
id VARCHAR(64) PRIMARY KEY, -- SHA256(email + salt)
|
|
password_hash VARCHAR(255) NOT NULL, -- bcrypt
|
|
user_type VARCHAR(20) NOT NULL, -- 'pro' ou 'demandeur'
|
|
token_balance INT DEFAULT 0,
|
|
token_premium_balance INT DEFAULT 0, -- optionnel v2
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
banned BOOLEAN DEFAULT FALSE,
|
|
ban_reason TEXT
|
|
);
|
|
|
|
-- Demandes
|
|
CREATE TABLE demandes (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
author_id VARCHAR(64) REFERENCES users(id),
|
|
title VARCHAR(100) NOT NULL,
|
|
description TEXT NOT NULL,
|
|
category VARCHAR(50) NOT NULL,
|
|
location GEOGRAPHY(POINT, 4326), -- PostGIS pour géoloc
|
|
location_display VARCHAR(100), -- "Paris 11e" (flouté)
|
|
budget_min INT,
|
|
budget_max INT,
|
|
status VARCHAR(20) DEFAULT 'active', -- active/full/resolved/expired
|
|
slots_used INT DEFAULT 0, -- nombre de pros ayant débloqué
|
|
slots_premium_used INT DEFAULT 0, -- optionnel v2
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
expires_at TIMESTAMP, -- created_at + 30 jours
|
|
flagged BOOLEAN DEFAULT FALSE,
|
|
flag_reason TEXT
|
|
);
|
|
|
|
CREATE INDEX idx_demandes_status ON demandes(status);
|
|
CREATE INDEX idx_demandes_location ON demandes USING GIST(location);
|
|
CREATE INDEX idx_demandes_category ON demandes(category);
|
|
|
|
-- Transactions (paiements externes)
|
|
CREATE TABLE transactions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id VARCHAR(64) REFERENCES users(id),
|
|
stripe_payment_id VARCHAR(255) NOT NULL,
|
|
amount_eur DECIMAL(10, 2) NOT NULL,
|
|
tokens_credited INT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_transactions_user ON transactions(user_id);
|
|
CREATE INDEX idx_transactions_stripe ON transactions(stripe_payment_id);
|
|
|
|
-- Unlocks (qui a débloqué quoi)
|
|
CREATE TABLE unlocks (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
pro_id VARCHAR(64) REFERENCES users(id),
|
|
demande_id UUID REFERENCES demandes(id),
|
|
tokens_spent INT NOT NULL,
|
|
is_premium BOOLEAN DEFAULT FALSE,
|
|
unlocked_at TIMESTAMP DEFAULT NOW(),
|
|
refunded BOOLEAN DEFAULT FALSE,
|
|
refund_reason TEXT
|
|
);
|
|
|
|
CREATE INDEX idx_unlocks_pro ON unlocks(pro_id);
|
|
CREATE INDEX idx_unlocks_demande ON unlocks(demande_id);
|
|
|
|
-- Conversations (blobs chiffrés)
|
|
CREATE TABLE conversations (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_a_id VARCHAR(64) REFERENCES users(id),
|
|
user_b_id VARCHAR(64) REFERENCES users(id),
|
|
blob_for_a TEXT, -- JSON chiffré pour A
|
|
blob_for_b TEXT, -- JSON chiffré pour B
|
|
message_count INT DEFAULT 0,
|
|
last_updated TIMESTAMP DEFAULT NOW(),
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
auto_delete_at TIMESTAMP -- created_at + 6 mois
|
|
);
|
|
|
|
CREATE INDEX idx_conv_user_a ON conversations(user_a_id);
|
|
CREATE INDEX idx_conv_user_b ON conversations(user_b_id);
|
|
CREATE INDEX idx_conv_auto_delete ON conversations(auto_delete_at);
|
|
|
|
-- Flags (modération)
|
|
CREATE TABLE conversation_flags (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
conversation_id UUID REFERENCES conversations(id),
|
|
user_id VARCHAR(64) REFERENCES users(id), -- user qui a généré les flags
|
|
flag_type VARCHAR(50) NOT NULL, -- contact_externe, paiement_externe, suspect
|
|
count INT DEFAULT 1,
|
|
last_flagged TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_flags_conv ON conversation_flags(conversation_id);
|
|
CREATE INDEX idx_flags_user ON conversation_flags(user_id);
|
|
|
|
-- Signalements (user reports)
|
|
CREATE TABLE reports (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
reporter_id VARCHAR(64) REFERENCES users(id),
|
|
reported_id VARCHAR(64) REFERENCES users(id),
|
|
reason TEXT NOT NULL,
|
|
conversation_id UUID REFERENCES conversations(id),
|
|
status VARCHAR(20) DEFAULT 'pending', -- pending/reviewed/actioned
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
-- Médias (optionnel v2)
|
|
CREATE TABLE media_files (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
conversation_id UUID REFERENCES conversations(id),
|
|
uploaded_by VARCHAR(64) REFERENCES users(id),
|
|
file_encrypted BYTEA, -- ou URL S3
|
|
file_type VARCHAR(10), -- image/video
|
|
file_size INT,
|
|
uploaded_at TIMESTAMP DEFAULT NOW(),
|
|
expires_at TIMESTAMP, -- uploaded_at + 14 jours
|
|
deleted BOOLEAN DEFAULT FALSE
|
|
);
|
|
|
|
CREATE INDEX idx_media_expires ON media_files(expires_at) WHERE deleted = FALSE;
|
|
```
|
|
|
|
### 2.2 Tâches cron automatiques
|
|
|
|
**Script à exécuter quotidiennement (3h du matin) :**
|
|
|
|
```javascript
|
|
// Suppression conversations > 6 mois
|
|
DELETE FROM conversations
|
|
WHERE last_updated < NOW() - INTERVAL '6 months';
|
|
|
|
// Suppression demandes expirées
|
|
DELETE FROM demandes
|
|
WHERE expires_at < NOW();
|
|
|
|
// Suppression médias expirés
|
|
DELETE FROM media_files
|
|
WHERE expires_at < NOW() AND deleted = FALSE;
|
|
|
|
// Warning 30 jours avant suppression conversation
|
|
SELECT user_a_id, user_b_id, id
|
|
FROM conversations
|
|
WHERE last_updated < NOW() - INTERVAL '5 months'
|
|
AND last_updated > NOW() - INTERVAL '5 months 1 day';
|
|
// → Envoyer notifications
|
|
|
|
// Ban automatique si trop de flags
|
|
UPDATE users
|
|
SET banned = TRUE, ban_reason = 'Auto-ban: excessive flags'
|
|
WHERE id IN (
|
|
SELECT user_id
|
|
FROM conversation_flags
|
|
GROUP BY user_id
|
|
HAVING SUM(count) > 50
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Paiements Stripe
|
|
|
|
### 3.1 Intégration Stripe Checkout (recommandé pour MVP)
|
|
|
|
**Flow :**
|
|
|
|
```javascript
|
|
// Frontend : User clique "Acheter 50 tokens"
|
|
const response = await fetch('/api/tokens/create-checkout', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${jwt}` },
|
|
body: JSON.stringify({ pack: '50' })
|
|
});
|
|
|
|
const { sessionId } = await response.json();
|
|
|
|
// Redirige vers Stripe Checkout
|
|
const stripe = Stripe(STRIPE_PUBLIC_KEY);
|
|
await stripe.redirectToCheckout({ sessionId });
|
|
```
|
|
|
|
**Backend :**
|
|
|
|
```javascript
|
|
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
|
|
|
app.post('/api/tokens/create-checkout', authenticateJWT, async (req, res) => {
|
|
const { pack } = req.body;
|
|
const userId = req.user.userId;
|
|
|
|
// Définir les packs
|
|
const packs = {
|
|
'10': { price: 10, tokens: 10 },
|
|
'25': { price: 25, tokens: 30 },
|
|
'50': { price: 50, tokens: 70 },
|
|
'100': { price: 100, tokens: 150 }
|
|
};
|
|
|
|
const selectedPack = packs[pack];
|
|
if (!selectedPack) return res.status(400).json({ error: 'Invalid pack' });
|
|
|
|
// Créer session Stripe
|
|
const session = await stripe.checkout.sessions.create({
|
|
payment_method_types: ['card'],
|
|
line_items: [{
|
|
price_data: {
|
|
currency: 'eur',
|
|
product_data: {
|
|
name: `${selectedPack.tokens} tokens LeBonCoup`
|
|
},
|
|
unit_amount: selectedPack.price * 100 // en centimes
|
|
},
|
|
quantity: 1
|
|
}],
|
|
mode: 'payment',
|
|
success_url: `${process.env.FRONTEND_URL}/tokens/success`,
|
|
cancel_url: `${process.env.FRONTEND_URL}/tokens/cancel`,
|
|
metadata: {
|
|
userId,
|
|
tokens: selectedPack.tokens
|
|
}
|
|
});
|
|
|
|
res.json({ sessionId: session.id });
|
|
});
|
|
```
|
|
|
|
### 3.2 Webhook Stripe (CRITIQUE)
|
|
|
|
**Configuration Stripe :**
|
|
- Dashboard Stripe → Developers → Webhooks
|
|
- Ajouter endpoint : `https://tonsite.com/api/webhooks/stripe`
|
|
- Événements à écouter : `checkout.session.completed`
|
|
|
|
**Backend :**
|
|
|
|
```javascript
|
|
app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
const sig = req.headers['stripe-signature'];
|
|
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
|
|
let event;
|
|
|
|
try {
|
|
// VÉRIFICATION DE LA SIGNATURE (CRITIQUE)
|
|
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
|
|
} catch (err) {
|
|
console.error('Webhook signature verification failed:', err.message);
|
|
return res.status(400).send(`Webhook Error: ${err.message}`);
|
|
}
|
|
|
|
// Traiter l'événement
|
|
if (event.type === 'checkout.session.completed') {
|
|
const session = event.data.object;
|
|
const { userId, tokens } = session.metadata;
|
|
|
|
// Créditer les tokens
|
|
await db.query(
|
|
'UPDATE users SET token_balance = token_balance + $1 WHERE id = $2',
|
|
[tokens, userId]
|
|
);
|
|
|
|
// Enregistrer la transaction
|
|
await db.query(
|
|
'INSERT INTO transactions (user_id, stripe_payment_id, amount_eur, tokens_credited) VALUES ($1, $2, $3, $4)',
|
|
[userId, session.payment_intent, session.amount_total / 100, tokens]
|
|
);
|
|
|
|
console.log(`✅ Credited ${tokens} tokens to user ${userId}`);
|
|
}
|
|
|
|
res.json({ received: true });
|
|
});
|
|
```
|
|
|
|
**⚠️ SÉCURITÉ CRITIQUE :**
|
|
- **TOUJOURS** vérifier la signature Stripe
|
|
- Sans ça, n'importe qui peut envoyer un faux webhook et s'auto-créditer des tokens
|
|
- Ne JAMAIS faire confiance à un webhook non signé
|
|
|
|
---
|
|
|
|
## 4. Messagerie Chiffrée (Système de Blobs)
|
|
|
|
### 4.1 Chiffrement côté client
|
|
|
|
**Chiffrement d'un message :**
|
|
|
|
```javascript
|
|
async function encryptBlob(plaintextJSON, encryptionKey) {
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
|
|
const encrypted = await crypto.subtle.encrypt(
|
|
{ name: "AES-GCM", iv },
|
|
encryptionKey,
|
|
new TextEncoder().encode(JSON.stringify(plaintextJSON))
|
|
);
|
|
|
|
// Retourner IV + ciphertext encodé en base64
|
|
return {
|
|
iv: btoa(String.fromCharCode(...iv)),
|
|
data: btoa(String.fromCharCode(...new Uint8Array(encrypted)))
|
|
};
|
|
}
|
|
|
|
async function decryptBlob(encryptedBlob, encryptionKey) {
|
|
const iv = Uint8Array.from(atob(encryptedBlob.iv), c => c.charCodeAt(0));
|
|
const data = Uint8Array.from(atob(encryptedBlob.data), c => c.charCodeAt(0));
|
|
|
|
const decrypted = await crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv },
|
|
encryptionKey,
|
|
data
|
|
);
|
|
|
|
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
}
|
|
```
|
|
|
|
### 4.2 Structure du blob (déchiffré)
|
|
|
|
```json
|
|
{
|
|
"conversation_id": "uuid",
|
|
"participants": {
|
|
"user_a": "user_id_a",
|
|
"user_b": "user_id_b"
|
|
},
|
|
"messages": [
|
|
{
|
|
"id": "msg_uuid",
|
|
"from": "user_id_a",
|
|
"type": "text",
|
|
"content": "Bonjour, je suis intéressé...",
|
|
"timestamp": 1234567890,
|
|
"read": false
|
|
},
|
|
{
|
|
"id": "msg_uuid2",
|
|
"from": "user_id_b",
|
|
"type": "text",
|
|
"content": "Parfait, voici mes disponibilités...",
|
|
"timestamp": 1234567920,
|
|
"read": true
|
|
}
|
|
],
|
|
"last_read": {
|
|
"user_a": 1234567920,
|
|
"user_b": 1234567890
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.3 Envoi d'un message
|
|
|
|
**Frontend :**
|
|
|
|
```javascript
|
|
async function sendMessage(conversationId, recipientId, content) {
|
|
// 1. Récupérer les blobs existants
|
|
const response = await fetch(`/api/conversations/${conversationId}`, {
|
|
headers: { 'Authorization': `Bearer ${jwt}` }
|
|
});
|
|
const { blob_for_a, blob_for_b, user_a_id, user_b_id } = await response.json();
|
|
|
|
// 2. Déterminer qui est qui
|
|
const currentUserId = getCurrentUserId();
|
|
const myBlob = currentUserId === user_a_id ? blob_for_a : blob_for_b;
|
|
const theirBlob = currentUserId === user_a_id ? blob_for_b : blob_for_a;
|
|
|
|
// 3. Déchiffrer mon blob
|
|
const myKey = sessionStorage.getItem('encryptionKey');
|
|
const myData = await decryptBlob(JSON.parse(myBlob), myKey);
|
|
|
|
// 4. Ajouter le nouveau message
|
|
const newMessage = {
|
|
id: generateUUID(),
|
|
from: currentUserId,
|
|
type: 'text',
|
|
content,
|
|
timestamp: Date.now(),
|
|
read: false
|
|
};
|
|
myData.messages.push(newMessage);
|
|
|
|
// 5. Re-chiffrer pour moi
|
|
const newMyBlob = await encryptBlob(myData, myKey);
|
|
|
|
// 6. Dériver la clé du destinataire et chiffrer pour lui
|
|
// NOTE: Ici on suppose qu'on a la clé du destinataire
|
|
// En réalité, chaque user chiffre avec SA clé, donc on doit :
|
|
// - Soit récupérer leur clé publique (si on passe en asymétrique)
|
|
// - Soit re-construire leur blob côté serveur (impossible car on a pas leur clé)
|
|
//
|
|
// SOLUTION RÉELLE : Chaque user chiffre son propre blob
|
|
// Donc User A met à jour son blob, User B met à jour le sien
|
|
|
|
// Version simplifiée : on envoie juste notre blob mis à jour
|
|
await fetch(`/api/conversations/${conversationId}/message`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${jwt}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
myBlob: newMyBlob,
|
|
recipientId,
|
|
messagePreview: content.substring(0, 50) // pour notif
|
|
})
|
|
});
|
|
}
|
|
```
|
|
|
|
**Backend :**
|
|
|
|
```javascript
|
|
app.post('/api/conversations/:id/message', authenticateJWT, async (req, res) => {
|
|
const { id } = req.params;
|
|
const { myBlob, recipientId, messagePreview } = req.body;
|
|
const senderId = req.user.userId;
|
|
|
|
// 1. Mettre à jour le blob de l'expéditeur
|
|
await db.query(
|
|
`UPDATE conversations
|
|
SET ${senderId === 'user_a_id' ? 'blob_for_a' : 'blob_for_b'} = $1,
|
|
message_count = message_count + 1,
|
|
last_updated = NOW()
|
|
WHERE id = $2`,
|
|
[JSON.stringify(myBlob), id]
|
|
);
|
|
|
|
// 2. Envoyer notification push au destinataire
|
|
await sendPushNotification(recipientId, {
|
|
title: 'Nouveau message',
|
|
body: messagePreview,
|
|
conversationId: id
|
|
});
|
|
|
|
res.json({ success: true });
|
|
});
|
|
```
|
|
|
|
**⚠️ PROBLÈME IDENTIFIÉ :**
|
|
|
|
Avec des clés symétriques dérivées, **chaque user ne peut chiffrer QUE pour lui-même**.
|
|
|
|
**Solutions :**
|
|
|
|
**Option A : Chiffrement asymétrique (RSA/ECDH)**
|
|
- Chaque user a une paire clé publique/privée
|
|
- Clé publique stockée sur le serveur
|
|
- Messages chiffrés avec la clé publique du destinataire
|
|
- Plus complexe
|
|
|
|
**Option B : Blob personnel uniquement**
|
|
- Chaque user gère uniquement SON blob
|
|
- Quand User A envoie un message, il met à jour son blob
|
|
- Quand User B se connecte, il récupère son blob (qui contient les messages que A lui a envoyés)
|
|
- Le serveur reçoit deux blobs séparés et les synchronise
|
|
|
|
**Recommandation pour MVP : Option B (plus simple)**
|
|
|
|
---
|
|
|
|
## 5. Système de Flags (Modération)
|
|
|
|
### 5.1 Scan côté client
|
|
|
|
**Patterns de détection :**
|
|
|
|
```javascript
|
|
const DETECTION_PATTERNS = {
|
|
phone: /\b0[67]\d{8}\b|\b\+33\s?[67]\d{8}\b/,
|
|
email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/i,
|
|
social: /@(instagram|telegram|whatsapp|snapchat|facebook)/i,
|
|
payment: /\b(paypal|venmo|cash|espèces|liquide|virement|western\s?union)\b/i,
|
|
suspect: /\b(discret|sans facture|urgent|livraison rapide|entre nous)\b/i
|
|
};
|
|
|
|
function scanMessage(content) {
|
|
const flags = [];
|
|
|
|
if (DETECTION_PATTERNS.phone.test(content)) {
|
|
flags.push('contact_externe');
|
|
}
|
|
if (DETECTION_PATTERNS.email.test(content)) {
|
|
flags.push('contact_externe');
|
|
}
|
|
if (DETECTION_PATTERNS.social.test(content)) {
|
|
flags.push('contact_externe');
|
|
}
|
|
if (DETECTION_PATTERNS.payment.test(content)) {
|
|
flags.push('paiement_externe');
|
|
}
|
|
if (DETECTION_PATTERNS.suspect.test(content)) {
|
|
flags.push('activité_suspecte');
|
|
}
|
|
|
|
return flags;
|
|
}
|
|
```
|
|
|
|
### 5.2 Envoi des flags au serveur
|
|
|
|
```javascript
|
|
async function sendMessage(conversationId, content) {
|
|
// 1. Scan du message
|
|
const flags = scanMessage(content);
|
|
|
|
// 2. Si flags détectés, envoyer au serveur (asynchrone)
|
|
if (flags.length > 0) {
|
|
fetch('/api/flags', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${jwt}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
conversationId,
|
|
flags,
|
|
timestamp: Date.now()
|
|
})
|
|
}).catch(err => console.error('Failed to send flags:', err));
|
|
}
|
|
|
|
// 3. Chiffrer et envoyer le message normalement
|
|
// (ne pas bloquer l'envoi)
|
|
await encryptAndSendMessage(conversationId, content);
|
|
}
|
|
```
|
|
|
|
### 5.3 Traitement serveur
|
|
|
|
```javascript
|
|
app.post('/api/flags', authenticateJWT, async (req, res) => {
|
|
const { conversationId, flags } = req.body;
|
|
const userId = req.user.userId;
|
|
|
|
// Incrémenter les compteurs
|
|
for (const flagType of flags) {
|
|
await db.query(
|
|
`INSERT INTO conversation_flags (conversation_id, user_id, flag_type, count, last_flagged)
|
|
VALUES ($1, $2, $3, 1, NOW())
|
|
ON CONFLICT (conversation_id, user_id, flag_type)
|
|
DO UPDATE SET count = conversation_flags.count + 1, last_flagged = NOW()`,
|
|
[conversationId, userId, flagType]
|
|
);
|
|
}
|
|
|
|
// Calculer le total de flags pour cet user
|
|
const result = await db.query(
|
|
'SELECT SUM(count) as total FROM conversation_flags WHERE user_id = $1',
|
|
[userId]
|
|
);
|
|
const totalFlags = parseInt(result.rows[0].total);
|
|
|
|
// Actions automatiques
|
|
if (totalFlags >= 50) {
|
|
await db.query('UPDATE users SET banned = TRUE, ban_reason = $1 WHERE id = $2',
|
|
['Auto-ban: 50+ flags', userId]);
|
|
} else if (totalFlags >= 25) {
|
|
// Restriction temporaire (TODO: implémenter système de restrictions)
|
|
} else if (totalFlags >= 10) {
|
|
// Warning (TODO: notification in-app)
|
|
}
|
|
|
|
res.json({ success: true });
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Géolocalisation (PostGIS)
|
|
|
|
### 6.1 Stockage d'une demande avec localisation
|
|
|
|
```javascript
|
|
app.post('/api/demandes', authenticateJWT, async (req, res) => {
|
|
const { title, description, category, lat, lon, budget_min, budget_max } = req.body;
|
|
const authorId = req.user.userId;
|
|
|
|
// Créer un point géographique
|
|
const result = await db.query(
|
|
`INSERT INTO demandes (author_id, title, description, category, location, location_display, budget_min, budget_max, expires_at)
|
|
VALUES ($1, $2, $3, $4, ST_SetSRID(ST_MakePoint($5, $6), 4326), $7, $8, $9, NOW() + INTERVAL '30 days')
|
|
RETURNING *`,
|
|
[authorId, title, description, category, lon, lat, getLocationDisplay(lat, lon), budget_min, budget_max]
|
|
);
|
|
|
|
res.json(result.rows[0]);
|
|
});
|
|
```
|
|
|
|
### 6.2 Filtrage par distance
|
|
|
|
```javascript
|
|
app.get('/api/demandes/nearby', authenticateJWT, async (req, res) => {
|
|
const { lat, lon, radius_km, category } = req.query;
|
|
|
|
const result = await db.query(
|
|
`SELECT *, ST_Distance(location, ST_SetSRID(ST_MakePoint($1, $2), 4326)) / 1000 as distance_km
|
|
FROM demandes
|
|
WHERE status = 'active'
|
|
AND category = $3
|
|
AND ST_DWithin(location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $4 * 1000)
|
|
ORDER BY distance_km ASC`,
|
|
[lon, lat, category, radius_km]
|
|
);
|
|
|
|
res.json(result.rows);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Sécurité
|
|
|
|
### 7.1 Checklist sécurité
|
|
|
|
**Backend :**
|
|
- ✅ HTTPS obligatoire (certificat SSL)
|
|
- ✅ Helmet.js (headers de sécurité)
|
|
- ✅ Rate limiting (express-rate-limit)
|
|
- ✅ CORS configuré strictement
|
|
- ✅ SQL injection : utiliser parameterized queries
|
|
- ✅ XSS : sanitize inputs (DOMPurify côté client)
|
|
- ✅ CSRF : tokens CSRF ou SameSite cookies
|
|
- ✅ JWT avec expiration courte (7 jours max)
|
|
- ✅ Webhook Stripe : vérification signature
|
|
|
|
**Frontend :**
|
|
- ✅ CSP (Content Security Policy)
|
|
- ✅ Pas de données sensibles en localStorage (sessionStorage OK)
|
|
- ✅ Clés de chiffrement en mémoire uniquement
|
|
- ✅ Logout = clear sessionStorage
|
|
|
|
**BDD :**
|
|
- ✅ Pas de port exposé publiquement
|
|
- ✅ Credentials forts
|
|
- ✅ Backups réguliers
|
|
- ✅ Chiffrement at-rest si possible
|
|
|
|
### 7.2 Rate limiting
|
|
|
|
```javascript
|
|
const rateLimit = require('express-rate-limit');
|
|
|
|
const apiLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 100, // 100 requêtes max
|
|
message: 'Too many requests, please try again later'
|
|
});
|
|
|
|
app.use('/api/', apiLimiter);
|
|
|
|
const authLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 5, // 5 tentatives de login max
|
|
message: 'Too many login attempts, please try again later'
|
|
});
|
|
|
|
app.use('/api/auth/login', authLimiter);
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Déploiement
|
|
|
|
### 8.1 Variables d'environnement
|
|
|
|
```bash
|
|
# .env
|
|
NODE_ENV=production
|
|
|
|
# Database
|
|
DATABASE_URL=postgresql://user:password@host:5432/leboncoup
|
|
|
|
# Secrets
|
|
JWT_SECRET=random_string_very_long_and_secure
|
|
SECRET_SALT=another_random_string_for_email_hashing
|
|
|
|
# Stripe
|
|
STRIPE_SECRET_KEY=sk_live_...
|
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
STRIPE_PUBLIC_KEY=pk_live_...
|
|
|
|
# Frontend
|
|
FRONTEND_URL=https://leboncoup.fr
|
|
|
|
# Optionnel (v2)
|
|
AWS_ACCESS_KEY_ID=...
|
|
AWS_SECRET_ACCESS_KEY=...
|
|
S3_BUCKET_NAME=leboncoup-media
|
|
```
|
|
|
|
### 8.2 Dockerfile (optionnel)
|
|
|
|
```dockerfile
|
|
FROM node:18-alpine
|
|
|
|
WORKDIR /app
|
|
|
|
COPY package*.json ./
|
|
RUN npm ci --only=production
|
|
|
|
COPY . .
|
|
|
|
EXPOSE 3000
|
|
|
|
CMD ["node", "server.js"]
|
|
```
|
|
|
|
### 8.3 Procédure de déploiement
|
|
|
|
**Option 1 : Railway (recommandé pour MVP)**
|
|
1. Connecter repo GitHub
|
|
2. Railway détecte Node.js automatiquement
|
|
3. Ajouter PostgreSQL addon
|
|
4. Configurer les env vars
|
|
5. Deploy automatique sur chaque push
|
|
|
|
**Option 2 : DigitalOcean App Platform**
|
|
- Similaire à Railway
|
|
- Managed PostgreSQL inclus
|
|
|
|
**Option 3 : AWS (plus complexe)**
|
|
- EC2 pour backend
|
|
- RDS PostgreSQL
|
|
- S3 pour médias
|
|
- CloudFront CDN
|
|
- Route53 DNS
|
|
|
|
---
|
|
|
|
## 9. Monitoring et Logs
|
|
|
|
### 9.1 Logs essentiels
|
|
|
|
```javascript
|
|
const winston = require('winston');
|
|
|
|
const logger = winston.createLogger({
|
|
level: 'info',
|
|
format: winston.format.json(),
|
|
transports: [
|
|
new winston.transports.File({ filename: 'error.log', level: 'error' }),
|
|
new winston.transports.File({ filename: 'combined.log' })
|
|
]
|
|
});
|
|
|
|
// Logs critiques
|
|
logger.info('User login', { userId, timestamp });
|
|
logger.info('Token purchase', { userId, amount, tokens });
|
|
logger.warn('Flag threshold reached', { userId, totalFlags });
|
|
logger.error('Webhook verification failed', { error });
|
|
```
|
|
|
|
### 9.2 Alertes
|
|
|
|
**Mettre en place des alertes pour :**
|
|
- Webhook Stripe échoué
|
|
- User atteint 50+ flags
|
|
- Erreurs serveur (500)
|
|
- Usage disque > 80%
|
|
- CPU > 90%
|
|
|
|
**Outils recommandés :**
|
|
- Sentry (monitoring d'erreurs)
|
|
- Better Uptime (uptime monitoring)
|
|
- Logtail (logs centralisés)
|
|
|
|
---
|
|
|
|
## 10. Tests
|
|
|
|
### 10.1 Tests critiques
|
|
|
|
**Backend :**
|
|
```javascript
|
|
// Test webhook Stripe
|
|
test('Webhook Stripe valide crédite les tokens', async () => {
|
|
const event = createMockStripeEvent();
|
|
const response = await request(app)
|
|
.post('/api/webhooks/stripe')
|
|
.set('stripe-signature', generateValidSignature(event))
|
|
.send(event);
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
const user = await db.getUserById(userId);
|
|
expect(user.token_balance).toBe(50);
|
|
});
|
|
|
|
// Test flags auto-ban
|
|
test('User avec 50 flags est automatiquement banni', async () => {
|
|
await createFlagsForUser(userId, 50);
|
|
|
|
const user = await db.getUserById(userId);
|
|
expect(user.banned).toBe(true);
|
|
});
|
|
```
|
|
|
|
**Frontend :**
|
|
```javascript
|
|
// Test chiffrement/déchiffrement
|
|
test('Message chiffré puis déchiffré = message original', async () => {
|
|
const key = await deriveEncryptionKey('test@example.com', 'password123');
|
|
const message = { content: 'Hello', timestamp: Date.now() };
|
|
|
|
const encrypted = await encryptBlob(message, key);
|
|
const decrypted = await decryptBlob(encrypted, key);
|
|
|
|
expect(decrypted.content).toBe('Hello');
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 11. Performance
|
|
|
|
### 11.1 Optimisations BDD
|
|
|
|
**Index critiques :**
|
|
```sql
|
|
CREATE INDEX CONCURRENTLY idx_demandes_active ON demandes(status) WHERE status = 'active';
|
|
CREATE INDEX CONCURRENTLY idx_conversations_recent ON conversations(last_updated DESC);
|
|
CREATE INDEX CONCURRENTLY idx_unlocks_pro_recent ON unlocks(pro_id, unlocked_at DESC);
|
|
```
|
|
|
|
**Requêtes optimisées :**
|
|
- Utiliser `LIMIT` partout
|
|
- Pagination (offset/limit ou cursor-based)
|
|
- Éviter les `SELECT *` (spécifier les colonnes)
|
|
|
|
### 11.2 Cache
|
|
|
|
**Redis (optionnel pour scale) :**
|
|
```javascript
|
|
// Cache du nombre de demandes actives
|
|
const cachedCount = await redis.get('demandes:active:count');
|
|
if (cachedCount) return parseInt(cachedCount);
|
|
|
|
const count = await db.query('SELECT COUNT(*) FROM demandes WHERE status = \'active\'');
|
|
await redis.set('demandes:active:count', count, 'EX', 300); // 5 min
|
|
```
|
|
|
|
---
|
|
|
|
## 12. Migration et Maintenance
|
|
|
|
### 12.1 Scripts de migration
|
|
|
|
**Utiliser un outil comme `node-pg-migrate` :**
|
|
|
|
```javascript
|
|
// migrations/001_initial_schema.js
|
|
exports.up = (pgm) => {
|
|
pgm.createTable('users', {
|
|
id: { type: 'varchar(64)', primaryKey: true },
|
|
password_hash: { type: 'varchar(255)', notNull: true },
|
|
// ...
|
|
});
|
|
};
|
|
|
|
exports.down = (pgm) => {
|
|
pgm.dropTable('users');
|
|
};
|
|
```
|
|
|
|
### 12.2 Backups
|
|
|
|
**PostgreSQL :**
|
|
```bash
|
|
# Backup quotidien automatique
|
|
pg_dump -U user -h host leboncoup > backup_$(date +%Y%m%d).sql
|
|
|
|
# Restauration
|
|
psql -U user -h host leboncoup < backup_20250116.sql
|
|
```
|
|
|
|
---
|
|
|
|
**Date de création :** 2025-01-16
|
|
**Version :** 1.0
|
|
**Statut :** Draft pour implémentation
|