mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 16:32:07 +00:00
913 lines
33 KiB
JavaScript
913 lines
33 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const jwt = require('jsonwebtoken');
|
|
const axios = require('axios');
|
|
const {
|
|
getPool,
|
|
SCHEMA_BOOTSTRAP_LOCK_NAME,
|
|
withMysqlAdvisoryLock
|
|
} = require('./mysqlPool');
|
|
const { verifyAccessKey } = require('./checkVip');
|
|
const { verifyTurnstileFromRequest } = require('./utils/turnstile');
|
|
// Réutiliser l'instance Redis partagée au lieu d'en créer une nouvelle (évite les fuites mémoire)
|
|
const { redis } = require('./config/redis');
|
|
|
|
// === JWT_SECRET ===
|
|
const JWT_SECRET = process.env.JWT_SECRET;
|
|
|
|
// === MySQL pool caché ===
|
|
let _cachedPool = null;
|
|
let ensureSharedListsTablePromise = null;
|
|
function getCachedPool() {
|
|
if (!_cachedPool) _cachedPool = getPool();
|
|
return _cachedPool;
|
|
}
|
|
|
|
const SHARED_LIST_CACHE_TTL = 600; // 10 minutes
|
|
const USERS_DIR = path.join(__dirname, 'data', 'users');
|
|
|
|
// === OpenRouter API Configuration for content moderation (using Gemini 2.5 Flash Lite) ===
|
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
|
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
const OPENROUTER_MODEL = 'google/gemini-2.5-flash-lite';
|
|
const FRONTEND_BASE_URL = process.env.FRONTEND_BASE_URL;
|
|
|
|
// Fonction de modération des listes partagées avec OpenRouter/Gemini (exécutée en background)
|
|
async function moderateSharedListWithGemini(shareId, listName, items, username) {
|
|
try {
|
|
// Construire un résumé des items de la liste
|
|
const itemsSummary = (items || []).slice(0, 30).map(i => `${i.title || i.name || 'Sans titre'} (${i.type || 'inconnu'})`).join(', ');
|
|
|
|
const prompt = `Tu es un modérateur de contenu. Analyse le nom de la liste, le pseudo de l'utilisateur, et le contenu de la liste partagée suivants et réponds UNIQUEMENT par un JSON valide.
|
|
|
|
Pseudo de l'utilisateur: ${JSON.stringify(username)}
|
|
Nom de la liste: ${JSON.stringify(listName)}
|
|
Contenu de la liste (titres): ${JSON.stringify(itemsSummary)}
|
|
|
|
Critères de modération (s'appliquent au pseudo, au nom de la liste ET au contenu):
|
|
1. INSULTES: Le nom de la liste ou le pseudo contient des insultes, injures, propos haineux ou dégradants
|
|
2. EROTIQUE: Le nom de la liste ou le pseudo contient du contenu érotique, sexuel ou inapproprié
|
|
3. PSEUDO_INAPPROPRIE: Le pseudo contient des insultes, contenu érotique, ou est inapproprié
|
|
4. NOM_LISTE_INAPPROPRIE: Le nom de la liste est vulgaire, offensant ou inapproprié
|
|
5. CONTENU_INAPPROPRIE: La liste semble être une compilation de contenu à caractère pornographique ou extrêmement violent
|
|
|
|
Note: Les films et séries sont des contenus TMDB officiels, donc ils sont généralement acceptables. Ne flagge que si c'est CLAIREMENT inapproprié.
|
|
|
|
Réponds UNIQUEMENT avec ce format JSON (sans markdown, sans backticks):
|
|
{"flagged": true/false, "reason": "INSULTES" ou "EROTIQUE" ou "PSEUDO_INAPPROPRIE" ou "NOM_LISTE_INAPPROPRIE" ou "CONTENU_INAPPROPRIE" ou null, "details": "explication courte"}`;
|
|
|
|
const response = await axios.post(
|
|
OPENROUTER_API_URL,
|
|
{
|
|
model: OPENROUTER_MODEL,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
max_tokens: 500,
|
|
temperature: 0.1
|
|
},
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
|
'HTTP-Referer': FRONTEND_BASE_URL,
|
|
'X-Title': 'Movix Shared List Moderation'
|
|
},
|
|
timeout: 15000
|
|
}
|
|
);
|
|
|
|
const responseText = response.data?.choices?.[0]?.message?.content || '';
|
|
|
|
let moderationResult;
|
|
try {
|
|
const cleanedResponse = responseText.replace(/```json\n?|```\n?/g, '').trim();
|
|
moderationResult = JSON.parse(cleanedResponse);
|
|
} catch (parseError) {
|
|
console.error('❌ Erreur parsing réponse OpenRouter (shared list):', parseError, 'Response:', responseText);
|
|
return { flagged: false };
|
|
}
|
|
|
|
if (moderationResult.flagged) {
|
|
console.log(`🚨 Liste partagée flaggée (ID: ${shareId}): ${moderationResult.reason} - ${moderationResult.details}`);
|
|
|
|
// Marquer la liste comme modérée (retirer du catalogue)
|
|
await dbRun(
|
|
`UPDATE shared_lists SET is_public_in_catalog = 0, moderation_flagged = 1, moderation_reason = ?, moderation_details = ?, moderated_at = ? WHERE id = ?`,
|
|
[moderationResult.reason, moderationResult.details, Date.now(), shareId]
|
|
);
|
|
|
|
// Invalider le cache Redis
|
|
const row = await dbGet('SELECT share_code FROM shared_lists WHERE id = ?', [shareId]);
|
|
if (row) {
|
|
try { await redis.del(`sharedList:${row.share_code}`); } catch { /* ignore */ }
|
|
}
|
|
|
|
console.log(`✅ Liste partagée ID ${shareId} retirée du catalogue pour modération`);
|
|
}
|
|
|
|
return moderationResult;
|
|
} catch (error) {
|
|
console.error('❌ Erreur modération OpenRouter shared list (non bloquant):', error.message);
|
|
return { flagged: false };
|
|
}
|
|
}
|
|
|
|
// === Helper MySQL (même pattern que commentsRoutes.js) ===
|
|
async function ensureSharedListsTable() {
|
|
if (ensureSharedListsTablePromise) {
|
|
return ensureSharedListsTablePromise;
|
|
}
|
|
|
|
ensureSharedListsTablePromise = (async () => {
|
|
const pool = getCachedPool();
|
|
|
|
await withMysqlAdvisoryLock(pool, SCHEMA_BOOTSTRAP_LOCK_NAME, async () => {
|
|
await pool.execute(`
|
|
CREATE TABLE IF NOT EXISTS shared_lists (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
user_id VARCHAR(255) NOT NULL,
|
|
user_type VARCHAR(50) NOT NULL,
|
|
profile_id VARCHAR(255) NOT NULL,
|
|
list_id VARCHAR(255) NOT NULL,
|
|
share_code VARCHAR(20) NOT NULL,
|
|
is_public_in_catalog TINYINT(1) NOT NULL DEFAULT 0,
|
|
moderation_flagged TINYINT(1) NOT NULL DEFAULT 0,
|
|
moderation_reason VARCHAR(255),
|
|
moderation_details TEXT,
|
|
moderated_at BIGINT,
|
|
created_at BIGINT NOT NULL,
|
|
updated_at BIGINT NOT NULL,
|
|
UNIQUE KEY uq_share_code (share_code),
|
|
UNIQUE KEY uq_user_list (user_id, user_type, profile_id, list_id),
|
|
INDEX idx_shared_lists_share_code (share_code),
|
|
INDEX idx_shared_lists_user_profile (user_id, user_type, profile_id),
|
|
INDEX idx_shared_lists_moderation (moderation_flagged, is_public_in_catalog)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
`);
|
|
});
|
|
|
|
console.log('✅ Table MySQL shared_lists initialisée');
|
|
})().catch((error) => {
|
|
ensureSharedListsTablePromise = null;
|
|
console.error('❌ Erreur initialisation table MySQL shared_lists:', error.message);
|
|
throw error;
|
|
});
|
|
|
|
return ensureSharedListsTablePromise;
|
|
}
|
|
|
|
const dbRun = async (sql, params = []) => {
|
|
await ensureSharedListsTable();
|
|
const pool = getCachedPool();
|
|
const [result] = await pool.execute(sql, params);
|
|
return {
|
|
lastID: result.insertId || 0,
|
|
changes: result.affectedRows || 0
|
|
};
|
|
};
|
|
|
|
const dbGet = async (sql, params = []) => {
|
|
await ensureSharedListsTable();
|
|
const pool = getCachedPool();
|
|
const [rows] = await pool.execute(sql, params);
|
|
return rows.length > 0 ? rows[0] : null;
|
|
};
|
|
|
|
const dbAll = async (sql, params = []) => {
|
|
await ensureSharedListsTable();
|
|
const pool = getCachedPool();
|
|
const [rows] = await pool.execute(sql, params);
|
|
return rows;
|
|
};
|
|
|
|
// === Auth middleware (comme commentsRoutes.js) ===
|
|
const requireAuth = async (req, res, next) => {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
if (!token) {
|
|
return res.status(401).json({ error: 'Non authentifié' });
|
|
}
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
|
const { sub: userId, userType, sessionId } = decoded;
|
|
|
|
if (!['oauth', 'bip39'].includes(userType) || !userId || !sessionId) {
|
|
return res.status(401).json({ error: 'Token invalide' });
|
|
}
|
|
|
|
// Vérifier la session en MySQL
|
|
const pool = getCachedPool();
|
|
const [rows] = await pool.execute(
|
|
'SELECT id FROM user_sessions WHERE id = ? AND user_id = ? AND user_type = ?',
|
|
[sessionId, userId, userType]
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
return res.status(401).json({ error: 'Session invalide ou expirée' });
|
|
}
|
|
|
|
req.user = { userId, userType, sessionId };
|
|
next();
|
|
} catch (error) {
|
|
return res.status(401).json({ error: 'Token invalide' });
|
|
}
|
|
};
|
|
|
|
// === Admin middleware — vérifie que l'utilisateur est admin via la table admins MySQL ===
|
|
const requireAdmin = async (req, res, next) => {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
if (!token) {
|
|
return res.status(401).json({ error: 'Non authentifié' });
|
|
}
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
|
const { sub: userId, userType, sessionId } = decoded;
|
|
|
|
if (!['oauth', 'bip39'].includes(userType) || !userId || !sessionId) {
|
|
return res.status(401).json({ error: 'Token invalide' });
|
|
}
|
|
|
|
const pool = getCachedPool();
|
|
|
|
// Vérifier la session
|
|
const [sessionRows] = await pool.execute(
|
|
'SELECT id FROM user_sessions WHERE id = ? AND user_id = ? AND user_type = ?',
|
|
[sessionId, userId, userType]
|
|
);
|
|
if (sessionRows.length === 0) {
|
|
return res.status(401).json({ error: 'Session invalide ou expirée' });
|
|
}
|
|
|
|
// Vérifier le rôle admin
|
|
const [adminRows] = await pool.execute(
|
|
'SELECT * FROM admins WHERE user_id = ? AND auth_type = ?',
|
|
[userId, userType === 'bip39' ? 'bip-39' : userType]
|
|
);
|
|
if (adminRows.length === 0) {
|
|
return res.status(403).json({ error: 'Accès refusé - Droits admin requis' });
|
|
}
|
|
|
|
req.user = { userId, userType, sessionId };
|
|
req.admin = { userId, userType, adminId: adminRows[0].id, role: adminRows[0].role || 'admin' };
|
|
next();
|
|
} catch (error) {
|
|
return res.status(401).json({ error: 'Token invalide' });
|
|
}
|
|
};
|
|
|
|
// === Helper: vérifier que le profileId appartient bien à l'utilisateur ===
|
|
async function verifyProfileOwnership(userId, userType, profileId) {
|
|
try {
|
|
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
const safeProfileId = String(profileId).replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
|
|
let userFilePath;
|
|
if (userType === 'bip39') {
|
|
userFilePath = path.join(USERS_DIR, `bip39-${safeUserId}.json`);
|
|
} else {
|
|
userFilePath = path.join(USERS_DIR, `${safeUserId}.json`);
|
|
}
|
|
|
|
const userFile = await fs.readFile(userFilePath, 'utf8');
|
|
const user = JSON.parse(userFile);
|
|
|
|
if (user.profiles) {
|
|
return user.profiles.some(p => p.id === profileId);
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// === Helper: récupérer les données utilisateur (username, avatar, isVip) depuis le profil ===
|
|
async function getUserProfileData(userId, userType, profileId) {
|
|
try {
|
|
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
const safeProfileId = String(profileId).replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
const safeUserType = ['oauth', 'bip39'].includes(userType) ? userType : 'oauth';
|
|
|
|
let userFilePath;
|
|
if (userType === 'bip39') {
|
|
userFilePath = path.join(USERS_DIR, `bip39-${safeUserId}.json`);
|
|
} else {
|
|
userFilePath = path.join(USERS_DIR, `${safeUserId}.json`);
|
|
}
|
|
|
|
let username = 'Utilisateur';
|
|
let avatar = '/avatars/disney/disney_avatar_1.png';
|
|
let isVip = false;
|
|
|
|
try {
|
|
const userFile = await fs.readFile(userFilePath, 'utf8');
|
|
const user = JSON.parse(userFile);
|
|
|
|
if (user.profiles) {
|
|
const profile = user.profiles.find(p => p.id === profileId);
|
|
if (profile) {
|
|
username = profile.name || 'Utilisateur';
|
|
avatar = (profile.avatar && profile.avatar.startsWith('/avatars/')) ? profile.avatar : '/avatars/disney/disney_avatar_1.png';
|
|
}
|
|
}
|
|
} catch { /* fichier utilisateur introuvable */ }
|
|
|
|
// Vérifier le statut VIP en lisant l'access_code depuis les données du profil
|
|
try {
|
|
const profileDataPath = path.join(USERS_DIR, 'profiles', safeUserType, safeUserId, `${safeProfileId}.json`);
|
|
const profileData = JSON.parse(await fs.readFile(profileDataPath, 'utf8'));
|
|
const storedAccessCode = profileData.access_code || null;
|
|
if (storedAccessCode) {
|
|
const vipStatus = await verifyAccessKey(storedAccessCode);
|
|
isVip = vipStatus.vip;
|
|
}
|
|
} catch { /* pas de données de profil ou pas d'access_code */ }
|
|
|
|
return { username, avatar, isVip };
|
|
} catch {
|
|
return { username: 'Utilisateur', avatar: '/avatars/disney/disney_avatar_1.png', isVip: false };
|
|
}
|
|
}
|
|
|
|
// === Helper: récupérer la custom_lists depuis le fichier profil data ===
|
|
async function getProfileCustomLists(userId, userType, profileId) {
|
|
try {
|
|
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
const safeProfileId = String(profileId).replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
const safeUserType = ['oauth', 'bip39'].includes(userType) ? userType : 'oauth';
|
|
|
|
const profileDataPath = path.join(USERS_DIR, 'profiles', safeUserType, safeUserId, `${safeProfileId}.json`);
|
|
const profileData = JSON.parse(await fs.readFile(profileDataPath, 'utf8'));
|
|
|
|
const customListsRaw = profileData.custom_lists;
|
|
if (!customListsRaw) return [];
|
|
|
|
try {
|
|
return JSON.parse(customListsRaw);
|
|
} catch {
|
|
return [];
|
|
}
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// === Helper: générer un code de partage unique ===
|
|
function generateShareCode() {
|
|
return crypto.randomBytes(6).toString('base64url').slice(0, 10);
|
|
}
|
|
|
|
// === Helper: construire le cache de la liste partagée et le mettre dans Redis ===
|
|
async function buildAndCacheSharedList(shareCode, userId, userType, profileId, listId, isPublicInCatalog = false) {
|
|
try {
|
|
// Récupérer les données du profil (username, avatar)
|
|
const profileData = await getUserProfileData(userId, userType, profileId);
|
|
|
|
// Récupérer les listes personnalisées du profil
|
|
const customLists = await getProfileCustomLists(userId, userType, profileId);
|
|
|
|
// Trouver la liste spécifique
|
|
const list = customLists.find(l => String(l.id) === String(listId));
|
|
if (!list) {
|
|
return null;
|
|
}
|
|
|
|
const cachedData = {
|
|
shareCode,
|
|
username: profileData.username,
|
|
avatar: profileData.avatar,
|
|
isVip: profileData.isVip,
|
|
listName: list.name,
|
|
items: list.items || [],
|
|
itemCount: (list.items || []).length,
|
|
isPublicInCatalog: !!isPublicInCatalog,
|
|
cachedAt: Date.now()
|
|
};
|
|
|
|
// Mettre en cache dans Redis
|
|
try {
|
|
await redis.set(
|
|
`sharedList:${shareCode}`,
|
|
JSON.stringify(cachedData),
|
|
'EX',
|
|
SHARED_LIST_CACHE_TTL
|
|
);
|
|
} catch { /* Redis indisponible */ }
|
|
|
|
return cachedData;
|
|
} catch (error) {
|
|
console.error('Erreur buildAndCacheSharedList:', error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
|
|
// ========================================
|
|
// ROUTES
|
|
// ========================================
|
|
|
|
// POST /share — Partager une liste
|
|
router.post('/share', requireAuth, async (req, res) => {
|
|
try {
|
|
const { profileId, listId, publishToCatalog, turnstileToken } = req.body;
|
|
const { userId, userType } = req.user;
|
|
const isPublicInCatalog = !!publishToCatalog;
|
|
|
|
// Vérification Turnstile (managed captcha)
|
|
const turnstileResult = await verifyTurnstileFromRequest(req, turnstileToken);
|
|
if (!turnstileResult.valid) return res.status(turnstileResult.status).json({ error: turnstileResult.error });
|
|
|
|
if (!profileId || !listId) {
|
|
return res.status(400).json({ error: 'profileId et listId requis' });
|
|
}
|
|
|
|
// Vérifier que le profil appartient bien à l'utilisateur
|
|
const isOwner = await verifyProfileOwnership(userId, userType, profileId);
|
|
if (!isOwner) {
|
|
return res.status(403).json({ error: 'Ce profil ne vous appartient pas' });
|
|
}
|
|
|
|
// Vérifier que la liste existe dans les données du profil
|
|
const customLists = await getProfileCustomLists(userId, userType, profileId);
|
|
const list = customLists.find(l => String(l.id) === String(listId));
|
|
if (!list) {
|
|
return res.status(404).json({ error: 'Liste non trouvée' });
|
|
}
|
|
|
|
// Vérifier si la liste est déjà partagée
|
|
const existing = await dbGet(
|
|
'SELECT share_code FROM shared_lists WHERE user_id = ? AND user_type = ? AND profile_id = ? AND list_id = ?',
|
|
[userId, userType, profileId, String(listId)]
|
|
);
|
|
|
|
if (existing) {
|
|
const now = Date.now();
|
|
await dbRun(
|
|
'UPDATE shared_lists SET is_public_in_catalog = ?, updated_at = ? WHERE user_id = ? AND user_type = ? AND profile_id = ? AND list_id = ?',
|
|
[isPublicInCatalog ? 1 : 0, now, userId, userType, profileId, String(listId)]
|
|
);
|
|
|
|
// Si on publie au catalogue, reset le flag de modération et lancer modération Gemini en background
|
|
if (isPublicInCatalog) {
|
|
await dbRun(
|
|
'UPDATE shared_lists SET moderation_flagged = 0, moderation_reason = NULL, moderation_details = NULL, moderated_at = NULL WHERE user_id = ? AND user_type = ? AND profile_id = ? AND list_id = ?',
|
|
[userId, userType, profileId, String(listId)]
|
|
);
|
|
const existingRecord = await dbGet(
|
|
'SELECT id FROM shared_lists WHERE user_id = ? AND user_type = ? AND profile_id = ? AND list_id = ?',
|
|
[userId, userType, profileId, String(listId)]
|
|
);
|
|
const profileData = await getUserProfileData(userId, userType, profileId);
|
|
moderateSharedListWithGemini(existingRecord.id, list.name, list.items || [], profileData.username)
|
|
.catch(err => console.error('Erreur modération Gemini liste (non bloquant):', err));
|
|
}
|
|
|
|
// Rafraîchir le cache et retourner le code existant
|
|
await buildAndCacheSharedList(existing.share_code, userId, userType, profileId, listId, isPublicInCatalog);
|
|
return res.json({ shareCode: existing.share_code, alreadyShared: true, isPublicInCatalog });
|
|
}
|
|
|
|
// Générer un code de partage unique
|
|
let shareCode;
|
|
let attempts = 0;
|
|
do {
|
|
shareCode = generateShareCode();
|
|
const dup = await dbGet('SELECT id FROM shared_lists WHERE share_code = ?', [shareCode]);
|
|
if (!dup) break;
|
|
attempts++;
|
|
} while (attempts < 10);
|
|
|
|
if (attempts >= 10) {
|
|
return res.status(500).json({ error: 'Impossible de générer un code unique' });
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
// Insérer dans MySQL (seulement la référence, pas les données)
|
|
await dbRun(
|
|
'INSERT INTO shared_lists (user_id, user_type, profile_id, list_id, share_code, is_public_in_catalog, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
[userId, userType, profileId, String(listId), shareCode, isPublicInCatalog ? 1 : 0, now, now]
|
|
);
|
|
|
|
// Construire et mettre en cache la liste
|
|
await buildAndCacheSharedList(shareCode, userId, userType, profileId, listId, isPublicInCatalog);
|
|
|
|
// Si publié au catalogue, lancer modération Gemini en background
|
|
if (isPublicInCatalog) {
|
|
const insertedRecord = await dbGet(
|
|
'SELECT id FROM shared_lists WHERE share_code = ?',
|
|
[shareCode]
|
|
);
|
|
const profileData = await getUserProfileData(userId, userType, profileId);
|
|
moderateSharedListWithGemini(insertedRecord.id, list.name, list.items || [], profileData.username)
|
|
.catch(err => console.error('Erreur modération Gemini liste (non bloquant):', err));
|
|
}
|
|
|
|
res.json({ shareCode, isPublicInCatalog });
|
|
} catch (error) {
|
|
console.error('Erreur POST /share:', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
// DELETE /share — Retirer le partage d'une liste
|
|
router.delete('/share', requireAuth, async (req, res) => {
|
|
try {
|
|
const { profileId, listId } = req.body;
|
|
const { userId, userType } = req.user;
|
|
|
|
if (!profileId || !listId) {
|
|
return res.status(400).json({ error: 'profileId et listId requis' });
|
|
}
|
|
|
|
// Vérifier que le profil appartient bien à l'utilisateur
|
|
const isOwner = await verifyProfileOwnership(userId, userType, profileId);
|
|
if (!isOwner) {
|
|
return res.status(403).json({ error: 'Ce profil ne vous appartient pas' });
|
|
}
|
|
|
|
// Récupérer le share_code pour invalider le cache
|
|
const existing = await dbGet(
|
|
'SELECT share_code FROM shared_lists WHERE user_id = ? AND user_type = ? AND profile_id = ? AND list_id = ?',
|
|
[userId, userType, profileId, String(listId)]
|
|
);
|
|
|
|
if (!existing) {
|
|
return res.status(404).json({ error: 'Partage non trouvé' });
|
|
}
|
|
|
|
// Supprimer de MySQL
|
|
await dbRun(
|
|
'DELETE FROM shared_lists WHERE user_id = ? AND user_type = ? AND profile_id = ? AND list_id = ?',
|
|
[userId, userType, profileId, String(listId)]
|
|
);
|
|
|
|
// Invalider le cache Redis
|
|
try {
|
|
await redis.del(`sharedList:${existing.share_code}`);
|
|
} catch { /* Redis indisponible */ }
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Erreur DELETE /share:', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
// GET /share/status — Obtenir le status de partage pour les listes d'un profil
|
|
router.get('/share/status', requireAuth, async (req, res) => {
|
|
try {
|
|
const { profileId } = req.query;
|
|
const { userId, userType } = req.user;
|
|
|
|
if (!profileId) {
|
|
return res.status(400).json({ error: 'profileId requis' });
|
|
}
|
|
|
|
const shared = await dbAll(
|
|
'SELECT list_id, share_code, created_at, is_public_in_catalog FROM shared_lists WHERE user_id = ? AND user_type = ? AND profile_id = ?',
|
|
[userId, userType, profileId]
|
|
);
|
|
|
|
// Retourner un objet { listId: shareCode }
|
|
const statusMap = {};
|
|
for (const row of shared) {
|
|
statusMap[row.list_id] = {
|
|
shareCode: row.share_code,
|
|
sharedAt: row.created_at,
|
|
isPublicInCatalog: !!row.is_public_in_catalog
|
|
};
|
|
}
|
|
|
|
res.json({ shared: statusMap });
|
|
} catch (error) {
|
|
console.error('Erreur GET /share/status:', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
// GET /list/:shareCode — Récupérer une liste partagée (route publique, pas besoin d'auth)
|
|
router.get('/list/:shareCode', async (req, res) => {
|
|
try {
|
|
const { shareCode } = req.params;
|
|
|
|
if (!shareCode || shareCode.length > 20) {
|
|
return res.status(400).json({ error: 'Code de partage invalide' });
|
|
}
|
|
|
|
// Vérifier le cache Redis d'abord
|
|
try {
|
|
const cached = await redis.get(`sharedList:${shareCode}`);
|
|
if (cached) {
|
|
return res.json(JSON.parse(cached));
|
|
}
|
|
} catch { /* Redis indisponible, on continue */ }
|
|
|
|
// Pas de cache : chercher dans MySQL
|
|
const shareRecord = await dbGet(
|
|
'SELECT user_id, user_type, profile_id, list_id, is_public_in_catalog FROM shared_lists WHERE share_code = ?',
|
|
[shareCode]
|
|
);
|
|
|
|
if (!shareRecord) {
|
|
return res.status(404).json({ error: 'Liste partagée non trouvée ou expirée' });
|
|
}
|
|
|
|
// Reconstruire et mettre en cache
|
|
const data = await buildAndCacheSharedList(
|
|
shareCode,
|
|
shareRecord.user_id,
|
|
shareRecord.user_type,
|
|
shareRecord.profile_id,
|
|
shareRecord.list_id,
|
|
!!shareRecord.is_public_in_catalog
|
|
);
|
|
|
|
if (!data) {
|
|
return res.status(404).json({ error: 'Liste non trouvée dans les données du profil' });
|
|
}
|
|
|
|
res.json(data);
|
|
} catch (error) {
|
|
console.error('Erreur GET /list/:shareCode:', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
// GET /catalog — Récupérer les listes publiques du catalogue (route publique)
|
|
router.get('/catalog', async (req, res) => {
|
|
try {
|
|
const limitRaw = Number(req.query.limit);
|
|
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(100, limitRaw)) : 50;
|
|
|
|
const rows = await dbAll(
|
|
'SELECT share_code, user_id, user_type, profile_id, list_id, created_at, updated_at, is_public_in_catalog FROM shared_lists WHERE is_public_in_catalog = 1 AND moderation_flagged = 0 ORDER BY updated_at DESC LIMIT ?',
|
|
[limit]
|
|
);
|
|
|
|
const likeStatsByShareCode = new Map();
|
|
if (rows.length > 0) {
|
|
const shareCodes = rows.map((row) => row.share_code);
|
|
const placeholders = shareCodes.map(() => '?').join(', ');
|
|
const likeRows = await dbAll(
|
|
`SELECT content_id,
|
|
SUM(CASE WHEN vote_type = 'like' THEN 1 ELSE 0 END) AS likes_count,
|
|
SUM(CASE WHEN vote_type = 'dislike' THEN 1 ELSE 0 END) AS dislikes_count
|
|
FROM likes
|
|
WHERE content_type = 'shared-list'
|
|
AND content_id IN (${placeholders})
|
|
GROUP BY content_id`,
|
|
shareCodes
|
|
);
|
|
|
|
likeRows.forEach((row) => {
|
|
likeStatsByShareCode.set(String(row.content_id), {
|
|
likesCount: Number(row.likes_count) || 0,
|
|
dislikesCount: Number(row.dislikes_count) || 0,
|
|
});
|
|
});
|
|
}
|
|
|
|
const lists = (await Promise.all(
|
|
rows.map(async (row) => {
|
|
const data = await buildAndCacheSharedList(
|
|
row.share_code,
|
|
row.user_id,
|
|
row.user_type,
|
|
row.profile_id,
|
|
row.list_id,
|
|
true
|
|
);
|
|
|
|
if (!data) return null;
|
|
|
|
const likeStats = likeStatsByShareCode.get(String(row.share_code)) || {
|
|
likesCount: 0,
|
|
dislikesCount: 0
|
|
};
|
|
|
|
return {
|
|
...data,
|
|
sharedAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
likesCount: likeStats.likesCount,
|
|
dislikesCount: likeStats.dislikesCount
|
|
};
|
|
})
|
|
)).filter(Boolean);
|
|
|
|
res.json({ lists, total: lists.length });
|
|
} catch (error) {
|
|
console.error('Erreur GET /catalog:', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
|
|
// ========================================
|
|
// ADMIN ROUTES
|
|
// ========================================
|
|
|
|
// GET /admin/public — Lister toutes les listes publiques du catalogue (admin uniquement)
|
|
router.get('/admin/public', requireAdmin, async (req, res) => {
|
|
try {
|
|
const { page = 1, limit = 50, search = '' } = req.query;
|
|
const safePage = Math.max(1, parseInt(page) || 1);
|
|
const safeLimit = Math.max(1, Math.min(parseInt(limit) || 50, 100));
|
|
const offset = (safePage - 1) * safeLimit;
|
|
|
|
const rows = await dbAll(
|
|
'SELECT * FROM shared_lists WHERE is_public_in_catalog = 1 AND moderation_flagged = 0 ORDER BY updated_at DESC LIMIT ? OFFSET ?',
|
|
[safeLimit, offset]
|
|
);
|
|
|
|
const totalResult = await dbGet(
|
|
'SELECT COUNT(*) as total FROM shared_lists WHERE is_public_in_catalog = 1 AND moderation_flagged = 0'
|
|
);
|
|
|
|
// Enrichir avec les données utilisateur et la liste
|
|
const enrichedLists = await Promise.all(rows.map(async (row) => {
|
|
const data = await buildAndCacheSharedList(
|
|
row.share_code, row.user_id, row.user_type, row.profile_id, row.list_id, true
|
|
);
|
|
return {
|
|
id: row.id,
|
|
shareCode: row.share_code,
|
|
userId: row.user_id,
|
|
userType: row.user_type,
|
|
profileId: row.profile_id,
|
|
listId: row.list_id,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
username: data?.username || 'Inconnu',
|
|
avatar: data?.avatar || null,
|
|
isVip: data?.isVip || false,
|
|
listName: data?.listName || 'Sans nom',
|
|
itemCount: data?.itemCount || 0,
|
|
items: (data?.items || []).slice(0, 6) // max 6 items pour l'aperçu admin
|
|
};
|
|
}));
|
|
|
|
// Stats globales
|
|
const statsResult = await dbGet(`
|
|
SELECT
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN is_public_in_catalog = 1 AND moderation_flagged = 0 THEN 1 ELSE 0 END) as public_count,
|
|
SUM(CASE WHEN moderation_flagged = 1 THEN 1 ELSE 0 END) as moderated_count
|
|
FROM shared_lists
|
|
`);
|
|
|
|
res.json({
|
|
lists: enrichedLists,
|
|
total: totalResult.total,
|
|
stats: {
|
|
total: statsResult.total,
|
|
publicCount: statsResult.public_count,
|
|
moderatedCount: statsResult.moderated_count
|
|
},
|
|
page: safePage,
|
|
limit: safeLimit,
|
|
hasMore: offset + enrichedLists.length < totalResult.total
|
|
});
|
|
} catch (error) {
|
|
console.error('Erreur GET /admin/public:', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
// DELETE /admin/public/:id — Supprimer une liste publique (admin uniquement)
|
|
router.delete('/admin/public/:id', requireAdmin, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Récupérer le share_code pour invalider le cache
|
|
const row = await dbGet('SELECT share_code FROM shared_lists WHERE id = ?', [id]);
|
|
if (!row) {
|
|
return res.status(404).json({ error: 'Liste non trouvée' });
|
|
}
|
|
|
|
// Supprimer de MySQL
|
|
await dbRun('DELETE FROM shared_lists WHERE id = ?', [id]);
|
|
|
|
// Invalider le cache Redis
|
|
try { await redis.del(`sharedList:${row.share_code}`); } catch { /* ignore */ }
|
|
|
|
console.log(`🗑️ Liste partagée ID ${id} supprimée par admin`);
|
|
res.json({ success: true, message: 'Liste supprimée' });
|
|
} catch (error) {
|
|
console.error('Erreur DELETE /admin/public/:id:', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
// GET /admin/moderated — Lister les listes flaggées par Gemini (admin uniquement)
|
|
router.get('/admin/moderated', requireAdmin, async (req, res) => {
|
|
try {
|
|
const { page = 1, limit = 50 } = req.query;
|
|
const safePage = Math.max(1, parseInt(page) || 1);
|
|
const safeLimit = Math.max(1, Math.min(parseInt(limit) || 50, 100));
|
|
const offset = (safePage - 1) * safeLimit;
|
|
|
|
const rows = await dbAll(
|
|
'SELECT * FROM shared_lists WHERE moderation_flagged = 1 ORDER BY moderated_at DESC LIMIT ? OFFSET ?',
|
|
[safeLimit, offset]
|
|
);
|
|
|
|
const totalResult = await dbGet(
|
|
'SELECT COUNT(*) as total FROM shared_lists WHERE moderation_flagged = 1'
|
|
);
|
|
|
|
// Enrichir avec les données utilisateur et la liste
|
|
const enrichedLists = await Promise.all(rows.map(async (row) => {
|
|
const profileData = await getUserProfileData(row.user_id, row.user_type, row.profile_id);
|
|
const customLists = await getProfileCustomLists(row.user_id, row.user_type, row.profile_id);
|
|
const list = customLists.find(l => String(l.id) === String(row.list_id));
|
|
|
|
return {
|
|
id: row.id,
|
|
shareCode: row.share_code,
|
|
userId: row.user_id,
|
|
userType: row.user_type,
|
|
profileId: row.profile_id,
|
|
listId: row.list_id,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
moderatedAt: row.moderated_at,
|
|
moderationReason: row.moderation_reason,
|
|
moderationDetails: row.moderation_details,
|
|
username: profileData.username,
|
|
avatar: profileData.avatar,
|
|
isVip: profileData.isVip,
|
|
listName: list?.name || 'Liste supprimée',
|
|
itemCount: list?.items?.length || 0,
|
|
items: (list?.items || []).slice(0, 6)
|
|
};
|
|
}));
|
|
|
|
res.json({
|
|
lists: enrichedLists,
|
|
total: totalResult.total,
|
|
page: safePage,
|
|
limit: safeLimit,
|
|
hasMore: offset + enrichedLists.length < totalResult.total
|
|
});
|
|
} catch (error) {
|
|
console.error('Erreur GET /admin/moderated:', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
// PUT /admin/moderated/:id/approve — Approuver une liste modérée (remettre dans le catalogue)
|
|
router.put('/admin/moderated/:id/approve', requireAdmin, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const row = await dbGet('SELECT share_code FROM shared_lists WHERE id = ? AND moderation_flagged = 1', [id]);
|
|
if (!row) {
|
|
return res.status(404).json({ error: 'Liste modérée non trouvée' });
|
|
}
|
|
|
|
// Remettre dans le catalogue et enlever le flag de modération
|
|
await dbRun(
|
|
'UPDATE shared_lists SET is_public_in_catalog = 1, moderation_flagged = 0 WHERE id = ?',
|
|
[id]
|
|
);
|
|
|
|
// Invalider le cache Redis pour forcer un rafraîchissement
|
|
try { await redis.del(`sharedList:${row.share_code}`); } catch { /* ignore */ }
|
|
|
|
console.log(`✅ Liste partagée ID ${id} approuvée par admin (remise au catalogue)`);
|
|
res.json({ success: true, message: 'Liste approuvée et remise au catalogue' });
|
|
} catch (error) {
|
|
console.error('Erreur PUT /admin/moderated/:id/approve:', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
// DELETE /admin/moderated/:id — Supprimer définitivement une liste modérée
|
|
router.delete('/admin/moderated/:id', requireAdmin, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const row = await dbGet('SELECT share_code FROM shared_lists WHERE id = ?', [id]);
|
|
if (!row) {
|
|
return res.status(404).json({ error: 'Liste non trouvée' });
|
|
}
|
|
|
|
await dbRun('DELETE FROM shared_lists WHERE id = ?', [id]);
|
|
|
|
try { await redis.del(`sharedList:${row.share_code}`); } catch { /* ignore */ }
|
|
|
|
console.log(`🗑️ Liste modérée ID ${id} supprimée définitivement par admin`);
|
|
res.json({ success: true, message: 'Liste supprimée définitivement' });
|
|
} catch (error) {
|
|
console.error('Erreur DELETE /admin/moderated/:id:', error.message);
|
|
res.status(500).json({ error: 'Erreur serveur' });
|
|
}
|
|
});
|
|
|
|
router.initSharedListsTable = ensureSharedListsTable;
|
|
|
|
module.exports = router;
|