mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 16:32:07 +00:00
feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement Gros lot de travail synchronisé depuis le repo de dev. Les grands axes : migration de domaine (movix.date + miroirs), deux nouvelles sources films/séries (1jour1film, Cinestream) et une n ouvelle source livetv, un fournisseur OAuth « Se connecter avec Movix », une modération de contenu par IA revue, plus un lot de durcissement mémoire/réseau, sécurité et perf côté API et front. ## 🌐 Domaines & miroirs - Domaine canonique `movix.chat`/`movix.golf`/`movix.tax` → **movix.date** partout (front, allow-lists CORS/domaine, Discord RPC). Miroir `movix.health` → **movix.online** (Footer, Header, pages d'aide CaMarchePlus/Dns/Miroirs). Lien Telegram du footer → `mysticsaba.com`. ~10 clés i18n mises à jour. ## 🎬 Sources de streaming (VOD) - **J1F (1jour1film)** — nouvelle source de bout en bout (`routes/j1f.js`, monté sur `/api/j1f`). Résout le domaine tournant via une page `/go/`, décode les blobs base64 `J1F_SRV`/`j1fEpsData` (Dooplay/WP), ne garde que les players uniques, ignore les agrégateurs (frembed/vidsrc/videasy). Cache stale-while-revalidate dédié. Players 1J1F lus via l'extension/userscript. - **Cinestream** — nouvelle source films (`routes/cinestream.js`, scrape Next.js `cinestream.info`, match par id TMDB autoritaire). Wiflix bascule ses **films** sur Cinestream (bot-shield flemmix pénible sur les films) ; la **TV** reste sur flemmix. - **Wiflix** — `flemmix.farm` → **`flemmix.golf`** ; handshake cookie (`makeWiflixSearchRequest`, `h_check=25`) ; sélecteurs de template corrigés. - **Cpasmal** — axios+proxy → `makeCpasmalRequest` (CycleTLS JA3) car Cloudflare 403 ; recherche GET → POST form (le WAF bloque le GET). - **Fstream** — cookie anti-bot `fsschal` ; source épisodes JS statique (`/static/series/<id>.js`) avant l'AJAX `episodes_p.php` ; fallback `get_seasons.php`. - **FranceTV** — `fetchSeasonEpisodesViaDeepPage` (pagination JSON « Afficher plus »). - **Coflix** — `coflix.date` → `coflix.trade` ; logs 429 silencés en double. - **proxyManager** — cinq helpers de contournement Cloudflare exportés (`makeCinestreamRequest`, `make1j1fRequest`, `makeCpasmalRequest`, handshake Wiflix `makeWiflixSearchRequest`/`wiflixHandshake`, `classifyCloudflare429` worker-vs-site) ; CycleTLS + rotation ProxyScrape, ne throw jamais sur statut HTTP. Coflix/LecteurVideo quittent les Cloudflare Workers pour ProxyScrape. ## 📺 Live TV — remplacement de Sosplay/Vavoo par FCTV33 - Source sport **Sosplay → FCTV33** (API RBTV, protobuf brut) réécrite de bout en bout dans `liveTvRoutes.js`, **Vavoo retiré** (manifest/catalogue/stream) — tout passe par `/api/livetv/*`. - Pipeline neuf : décodeur protobuf maison, signature `sfver` (bs keys + MD5), auto-découverte des domaines API/player tournants (`hubu.ru/fctvlink` + Nuxt SSR / `params` rot47, cache 30 min), scraper de matchs (`scrapeFctvMatches` : équipes/logos, ligue, pays, statut live, tri live-first), résolution de flux (HLS natif d'abord, embed en secours), token CDN (XOR keystream + base64, gate `rb-session`). - Route `GET /api/livetv/fctv/playlist` : proxy m3u8 intelligent (résout le token, réécrit les segments, retry sur token périmé, modes `raw` extension / `proxy` VIP). - **Matchs plus réservés aux VIP** (seul `iptv_` le reste) : flux natif proxifié pour les VIP, stub `_fctvLocal` résolu côté client par l'extension/userscript (`RESOLVE_FCTV`), fallback embed sinon. - Front `LiveTV.tsx` : `matches` → `fctv`, UI accordéon par match (logos, badges ligue/pays, score/timer live, favori), disclaimer, fix icône `music`. Rotations d'hôtes : `witv.team`→`witv.football`, `livetv876.me`→`livetv901.me`. ## 🧩 Extensions · Userscript · Discord RPC - **FCTV en lecture native** : handlers `SETUP_FCTV_HEADERS`/`RESOLVE_FCTV` (règle DNR Referer/Origin/UA sur `/cfall/s*` + résolveur local IP-bindé : protobuf-lite + rot47 + token XOR). - **Vavoo retiré** , `cinepulse.lol` → `purstream.mx`. - Fix extracteur **Sibnet** (`Range: bytes=0-0`) ; fix bug userscript `matchToRegex` (patterns ancrés `^…$` cassant `/cfall/s` → dé-ancrés) ; override dev `localhost:25565`. - Bumps de version (Chrome 1.3.3→1.3.8, Firefox 1.5.1→1.5.5, userscript 1.4.0→1.4.7) ; PreMid nouveau `clientId` Discord + `regExp` élargi (1.0.3→1.0.4). ## 🔑 OAuth (fournisseur « Se connecter avec Movix ») & codes d'accès - Refonte du flux d'autorisation OAuth (`OAuthAuthorizePage.tsx`) + routes (`routes/authRoutes.js`, `routes/oauth.js`) et stockage des clients OAuth étoffé (`utils/oauthClientsDb.js`, `routes/adminOauthApps.js` pour la gestion admin des apps). - `AccessCodeForm.tsx` retouché en cohérence avec le nouveau flux ; `checkVip.js` ajusté. - Session centralisée : `accountAuth.ts` expose `clearStoredAuthSession()` / `broadcastAuthChange()` (liste `AUTH_KEYS` unifiée), désormais utilisés partout à la déconnexion. ## 💬 Modération de contenu (IA) - Modèle OpenRouter `google/gemini-2.5-flash-lite` → `deepseek/deepseek-v4-flash` (`commentsRoutes.js`, `sharedListsRoutes.js`) ; fonctions/logs renommés `moderateContentWithGemini` → `moderateContentWithAI` (découplés du nom du modèle). - Prompt durci (critères INSULTES / DEMANDE_AJOUT resserrés) pour éviter les faux positifs sur questions, signalements de bug et plaintes de contenu manquant. - `AdminComments.tsx` : libellé « par Gemini » → clé i18n générique. ## 🧊 Hydracker / Darkiworld - **Sortie du debrid** : AllDebrid/RealDebrid supprimés (`utils/hydrackerLive.js` : branche provider retirée ; vars `ALLDEBRID_*`/`REALDEBRID_*` sorties du `.env`) — hydracker renvoie directement `rawUrl`/`directDL`. Liens servis depuis les snapshots SQLite locaux (`utils/darkiworldSqlite.js`, `darkino-backups/`). - **Anti-scraping** sur `/decode/:id` (`routes/darkiworld.js`) : watchlist + alerte webhook Discord (`DISCORD_SCRAPER_WEBHOOK`) et empoisonnement/blocage de réponse pour les IP listées (`SCRAPER_BLOCKED_IPS`). - **Mémoire** : cache d'agents proxy borné (`utils/lruMap.js`) qui détruit les sockets des agents évincés. ## 🛠️ Admin — propriété des liens & équipe - **Propriété des liens** : les liens streaming gagnent l'attribution `added_by` et l'ownership est appliquée des deux côtés (uploader = ses liens, admin = tous). GET public masqué (`stripLinkOwner`), `GET /admin/streaming-links/:type/:id` authentifié conserve le tag ; `DELETE /admin/links` par URL unique ; `PUT /admin/links` masse admin-only. - **Équipe** : `GET/POST/DELETE /admin/admins` + `GET /admin/team/history` (audit), identité via `adminIdentity.js` (option `preferProfile` → premier profil Movix). UI `AdminTeam.tsx`. - **Privilèges** (`middleware/auth.js`) : `isAdmin` rejette `role !== 'admin'` (403) — les uploaders ne passent plus les routes admin-only. Migration `add_role_to_admins.sql`. ## 🎁 Wrapped v2 - Cartes de partage : logique canvas extraite (`utils/wrappedCanvas.ts`, `utils/wrappedShareCards.ts`), formats de partage supplémentaires + preview. - Nouvelles slides : jour record, répartition par jour de semaine, champion de rewatch, âge ciné (année de sortie médiane pondérée par le temps de visionnage). - Perf : données depuis le payload backend (zéro re-fetch TMDB), trailer lazy, percentile non bloquant (réchauffe en arrière-plan, lock Redis `NX`), shuffle seedé. ## 🔐 Auth · Profils · VIP - **Pseudo forcé** : `validateProfileName` (1–32 chars, strip control/zero-width, + `maxLength={32}` côté UI Profile/WatchParty) ; les pseudos OAuth trop longs → `requiresUsernameChange` + modale bloquante `RequireUsernameChange.tsx`. - **Sync durci** (`ProfileContext.tsx`, `syncPolicy.js`) : anti-replay cross-user, filtrage des ops à clés non-syncables (`access_code`/`access_code_expires` exclues), « wipe guard » qui préserve le local sur profil vide inattendu, dédupe des `loadProfiles()` concurrents. Logout centralisé (voir OAuth). - **VIP** : `is_vip` préservé au chargement de profil (corrige un downgrade VIP inter-onglets qui relançait la popup pub) ; `vipDonations.js` poll de secours `payment-status.php` (via `ipn_token`, colonne `paygate_ipn_token`) → plus d'invoices bloquées. ## ▶️ Lecteur (HLS) - Basse latence opt-in : `isLowLatencyEnabled('movies')` (scope movies/livetv, `lowLatencyPref.ts`) au lieu du mode forcé ; `FloatingPlayer` aligné. - Sous-titres cinep : wrapper m3u8 synthétique pour les URI `.vtt`/`.srt` directes, `isSubtitleLoadError` (une piste cassée ne tue plus la vidéo), rendu `<i>/<b>/<u>`. - Menu sources regroupé par langue (VF/VFQ/VFF/VOSTFR). ## 📡 Cast (AirPlay / Chromecast) - Retrait du contournement proxy darkibox (`buildApiProxyUrl`) → cast direct de l'URL ; `runtime.ts` supprime `API_PROXY_BASE_URL`/`buildApiProxyUrl` devenus morts. - Paramètre `streamType` (`BUFFERED` par défaut) propagé jusqu'à `loadMediaOnCastWithFallback`. ## 📢 Pubs - Modes de popup (`adPopupMode.ts`) : `normal`, `auto`, `click-anywhere` ; toggle « +18 » séparé (`adAdultMode.ts`). `SCRIPT_AD_MODE_ENABLED` → `false`, URL du script changée. ## 🔎 Recherche & priorité des sources - `searchUtils.ts` : le terme original contourne le filtre « trop générique » (fix zéro-résultat sur titres courts : Baki, Gantz). - `animeSignals.ts` : détection donghua (CN/TW/HK ou `zh` + Animation, hors C-drama). - `sourceAutoSelect.ts` : `VFF`/`VFQ` normalisés en `vf` (sinon reléguées derrière VOSTFR). `sourcePriorityPrefs.ts` : schéma v2 → v3 (nouvel ordre par défaut + `j1f`, migration non destructive des ordres personnalisés). `extractionPrefs.ts` : +daddylive. ## 🔒 Sécurité - Sanitisation d'URL avant rendu en `href` : `CommentsSection.tsx` (liens markdown `http(s)://`) et `Greenlight/AdminLinkSubmissions.tsx` — bloque les `javascript:`. ## ⚙️ Infra & perf - **server.js** : recyclage périodique des workers (12h) + `[memstats]` + hook `SIGUSR2` → heap-snapshot. - **app.js** : monte `/api/j1f`, retire la route statique `/oauth-icons`, passe `makeCpasmalRequest` au router Cpasmal, `coflix.date` → `coflix.trade`. - **lazyWithRetry.ts / main.tsx / ErrorBoundary.tsx** : récupération chunk-load réécrite (retry + reload budgété), garde `removeChild/insertBefore`, écran soft « Mise à jour » au lieu du crash-report. - **UI** : `TopProgressBar` écrit `transform` directement sur un ref (compositor-only, plus de re-render) ; `blur-text` relâche `will-change` après l'animation. - **proxiesembed/server.py** : `curl_cffi` (JA3) pour `cinep-proxy` + retry SOCKS5 sur 403 ; lookup clé VIP réaligné sur `checkVip.js` ; log WARNING par défaut ; SOCKS5 forcé IPv4 ; wrapper VTT/SRT pour hls.js ; re-label `application/zip` → `video/mp2t` ; `cinepulse.lol` → `purstream.mx`. ## 🧹 Nettoyage & suppressions - **Front** : carte promo « Loadix » sur Download (remplace l'encart beta), « 1jour1film » ajouté à « C'est quoi Movix », fallback Darkino/Nightflix gutté en no-op (`api.movix.chat` décommissionné), classes CSS mortes retirées, fix `fetchPriority` → `fetchpriority`. - **Backend** : scrapers/utilitaires morts retirés — `routes/dragiv.js`, `routes/topstream.js`, `routes/proxy.js`, `utils/cloudflareSession.js`, `utils/hydrackerBatch.js`, `utils/hydrackerQueue.js'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 DeepSeek V4 Flash) ===
|
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
|
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
const OPENROUTER_MODEL = 'deepseek/deepseek-v4-flash';
|
|
const FRONTEND_BASE_URL = process.env.FRONTEND_BASE_URL;
|
|
|
|
// Fonction de modération des listes partagées avec OpenRouter (exécutée en background)
|
|
async function moderateSharedListWithAI(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 IA 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);
|
|
moderateSharedListWithAI(existingRecord.id, list.name, list.items || [], profileData.username)
|
|
.catch(err => console.error('Erreur modération IA 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 IA en background
|
|
if (isPublicInCatalog) {
|
|
const insertedRecord = await dbGet(
|
|
'SELECT id FROM shared_lists WHERE share_code = ?',
|
|
[shareCode]
|
|
);
|
|
const profileData = await getUserProfileData(userId, userType, profileId);
|
|
moderateSharedListWithAI(insertedRecord.id, list.name, list.items || [], profileData.username)
|
|
.catch(err => console.error('Erreur modération IA 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 l'IA (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;
|