MovixOpenSource/API/Mainapi/wishboardRoutes.js
Movix 8657a856f8 feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement
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>
2026-07-05 18:22:13 +02:00

1000 lines
40 KiB
JavaScript

/**
* Wishboard / Greenlight API Routes
* Content request management system
*
* Uses MySQL database (same as the rest of the app)
*/
const express = require('express');
const jwt = require('jsonwebtoken');
const fs = require('fs').promises;
const path = require('path');
const { verifyAccessKey } = require('./checkVip');
const { searchTmdb } = require('./utils/tmdbCache');
const { verifyTurnstileFromRequest } = require('./utils/turnstile');
const { resolveAdminIdentity } = require('./utils/adminIdentity');
const TURNSTILE_INVISIBLE_SECRETKEY = process.env.TURNSTILE_INVISIBLE_SECRETKEY;
const TMDB_API_URL = 'https://api.themoviedb.org/3';
// JWT Secret (same as commentsRoutes and server.js)
const JWT_SECRET = process.env.JWT_SECRET;
/**
* Search TMDB by name and return matching TMDB IDs.
* Utilise searchTmdb centralisé (cache Redis partagé).
*/
async function searchTmdbIds(query) {
try {
const [movieData, tvData] = await Promise.all([
searchTmdb(TMDB_API_URL, process.env.TMDB_API_KEY, 'movie', query, { page: 1 }).catch(() => null),
searchTmdb(TMDB_API_URL, process.env.TMDB_API_KEY, 'tv', query, { page: 1 }).catch(() => null)
]);
const movieIds = (movieData?.results || []).map(r => r.id);
const tvIds = (tvData?.results || []).map(r => r.id);
return [...new Set([...movieIds, ...tvIds])];
} catch (error) {
console.error('TMDB search error:', error.message);
return [];
}
}
function createWishboardRouter(mysqlPool, redis) {
const router = express.Router();
// Middleware to verify JWT token with MySQL session validation
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 que la session existe en MySQL
if (!mysqlPool) {
return res.status(503).json({ error: 'Service temporairement indisponible' });
}
const [rows] = await mysqlPool.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: userId,
userType: userType,
sessionId: sessionId
};
next();
} catch (error) {
return res.status(401).json({ error: 'Token invalide' });
}
};
// Middleware to check admin or uploader status (same pattern as server.js isUploaderOrAdmin)
const requireUploaderOrAdmin = async (req, res, next) => {
try {
if (!mysqlPool) {
return res.status(503).json({ error: 'Service temporairement indisponible' });
}
const authType = req.user.userType === 'bip39' ? 'bip-39' : req.user.userType;
const [rows] = await mysqlPool.execute(
'SELECT * FROM admins WHERE user_id = ? AND auth_type = ?',
[req.user.userId, authType]
);
if (rows.length === 0) {
return res.status(403).json({ error: 'Accès refusé - Droits requis' });
}
// Récupérer le rôle (par défaut 'admin' si non défini)
const role = rows[0].role || 'admin';
// Autoriser les rôles 'admin' et 'uploader'
if (role !== 'admin' && role !== 'uploader') {
return res.status(403).json({ error: 'Accès refusé - Droits insuffisants' });
}
req.admin = { userId: req.user.userId, userType: req.user.userType, role };
next();
} catch (error) {
console.error('Admin/Uploader check error:', error);
return res.status(500).json({ error: 'Erreur lors de la vérification des droits' });
}
};
// Helper to get user data from JSON files (adapted from commentsRoutes.js)
async function getUserData(userId, userType, profileId = null) {
try {
let userData = { username: 'Utilisateur', avatar: null, isVip: false, isAdmin: false };
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
const safeUserType = ['oauth', 'bip39'].includes(userType) ? userType : 'oauth';
let userFilePath;
if (userType === 'bip39') {
userFilePath = path.join(__dirname, 'data', 'users', `bip39-${safeUserId}.json`);
} else {
userFilePath = path.join(__dirname, 'data', 'users', `${safeUserId}.json`);
}
try {
const userFile = await fs.readFile(userFilePath, 'utf8');
const user = JSON.parse(userFile);
if (user.profiles && user.profiles.length > 0) {
let profile;
if (profileId) {
profile = user.profiles.find(p => p.id === profileId);
}
if (!profile) {
profile = user.profiles[0];
}
if (profile) {
userData.username = profile.name || 'Utilisateur';
userData.avatar = profile.avatar;
}
}
} catch (err) {
console.error('Error reading user file:', err.message);
}
// Vérifier le statut VIP en lisant l'access_code depuis les données du profil
if (profileId && safeUserId) {
try {
const safeProfileId = String(profileId).replace(/[^a-zA-Z0-9_\-]/g, '');
const profileDataPath = path.join(__dirname, 'data', 'users', '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);
userData.isVip = vipStatus.vip;
}
} catch (err) {
// Pas de données de profil ou pas d'access_code — isVip reste false
}
}
return userData;
} catch (error) {
console.error('Error getUserData:', error);
return { isVip: false };
}
}
// Check request limit helper
const checkLimit = async (userId, userType, profileId, req) => {
// Enforce limits: 1 per 48h for Free, 3 per 48h for VIP
const [recentRequests] = await mysqlPool.execute(
`SELECT COUNT(*) as count FROM wishboard_requests
WHERE profile_id = ?
AND created_at >= DATE_SUB(NOW(), INTERVAL 48 HOUR)`,
[profileId]
);
const count = recentRequests[0].count;
// Vérifier le statut VIP en lisant l'access_code depuis les données du profil
let isVip = false;
if (profileId && userId) {
try {
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
const safeUserType = ['oauth', 'bip39'].includes(userType) ? userType : 'oauth';
const safeProfileId = String(profileId).replace(/[^a-zA-Z0-9_\-]/g, '');
const profileDataPath = path.join(__dirname, 'data', 'users', '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 (err) {
// Pas de données de profil — isVip reste false
}
}
const limit = isVip ? 3 : 1;
const remaining = Math.max(0, limit - count);
return { count, limit, remaining, isVip };
};
// Get profile ID from headers or body
const getProfileId = (req) => {
return req.headers['x-profile-id'] || req.body?.profileId || req.query?.profileId || null;
};
// Verify that the profileId belongs to the authenticated user
async function verifyProfileOwnership(userId, userType, profileId) {
try {
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
let userFilePath;
if (userType === 'bip39') {
userFilePath = path.join(__dirname, 'data', 'users', `bip39-${safeUserId}.json`);
} else {
userFilePath = path.join(__dirname, 'data', 'users', `${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 to record status change in history
const recordStatusHistory = async (requestId, status, reason = null, adminId = null, adminAuthType = null) => {
try {
const [result] = await mysqlPool.execute(
'INSERT INTO wishboard_status_history (request_id, status, reason, admin_id, admin_auth_type) VALUES (?, ?, ?, ?, ?)',
[requestId, status, reason, adminId, adminAuthType]
);
return result.insertId;
} catch (error) {
console.error('Error recording status history:', error);
return null;
}
};
// =====================================
// PUBLIC ENDPOINTS
// =====================================
/**
* GET /api/wishboard
* List all requests with filters and pagination
*/
router.get('/', async (req, res) => {
try {
const { page = 1, limit = 20, search, media_type, status, sort = 'votes_desc' } = req.query;
const offset = (parseInt(page) - 1) * parseInt(limit);
const profileId = getProfileId(req);
let whereConditions = ['1=1'];
let whereParams = [];
if (search) {
const isNumericSearch = /^\d+$/.test(search.trim());
if (isNumericSearch) {
// Direct TMDB ID search
whereConditions.push('tmdb_id = ?');
whereParams.push(parseInt(search));
} else {
// Text search: query TMDB API to find matching IDs
const tmdbIds = await searchTmdbIds(search.trim());
if (tmdbIds.length > 0) {
whereConditions.push(`tmdb_id IN (${tmdbIds.map(() => '?').join(',')})`);
whereParams.push(...tmdbIds);
} else {
// No TMDB results found, return empty
return res.json({ requests: [], has_more: false, stats: { total: 0, pending: 0, added_this_month: 0, movies: 0, tv: 0 } });
}
}
}
if (media_type && media_type !== 'all') {
whereConditions.push('media_type = ?');
whereParams.push(media_type);
}
if (status && status !== 'all') {
whereConditions.push('status = ?');
whereParams.push(status);
}
const whereClause = whereConditions.join(' AND ');
// Determine sort order
let orderBy = 'vote_count DESC';
switch (sort) {
case 'votes_asc':
orderBy = 'vote_count ASC';
break;
case 'date_desc':
orderBy = 'created_at DESC';
break;
case 'date_asc':
orderBy = 'created_at ASC';
break;
default:
orderBy = 'vote_count DESC';
}
// Get requests
const [requests] = await mysqlPool.execute(
`SELECT * FROM wishboard_requests WHERE ${whereClause} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
[...whereParams, parseInt(limit), offset]
);
// Check vote status and get public notes for each request
const requestsWithVotes = await Promise.all(requests.map(async (r) => {
let hasVoted = false;
if (profileId) {
const [voteRows] = await mysqlPool.execute(
'SELECT id FROM wishboard_votes WHERE request_id = ? AND profile_id = ?',
[r.id, profileId]
);
hasVoted = voteRows.length > 0;
}
// Get public notes
const [notes] = await mysqlPool.execute(
'SELECT id, note, is_public, created_at FROM wishboard_notes WHERE request_id = ? AND is_public = 1 ORDER BY created_at DESC',
[r.id]
);
// Get status history
const [statusHistory] = await mysqlPool.execute(
'SELECT status, reason, changed_at FROM wishboard_status_history WHERE request_id = ? ORDER BY changed_at ASC',
[r.id]
);
return { ...r, has_voted: hasVoted, notes, statusHistory };
}));
// Get total count
const [countResult] = await mysqlPool.execute(
`SELECT COUNT(*) as total FROM wishboard_requests WHERE ${whereClause}`,
whereParams
);
// Get stats
const [statsResult] = await mysqlPool.execute(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'added' AND updated_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as added_this_month,
SUM(CASE WHEN media_type = 'movie' THEN 1 ELSE 0 END) as movies,
SUM(CASE WHEN media_type = 'tv' THEN 1 ELSE 0 END) as tv
FROM wishboard_requests
`);
res.json({
requests: requestsWithVotes,
has_more: countResult[0].total > offset + requests.length,
stats: {
total: statsResult[0].total || 0,
pending: statsResult[0].pending || 0,
added_this_month: statsResult[0].added_this_month || 0,
movies: statsResult[0].movies || 0,
tv: statsResult[0].tv || 0
}
});
} catch (error) {
console.error('Error fetching wishboard:', error);
res.status(500).json({ error: 'Failed to fetch wishboard' });
}
});
/**
* POST /api/wishboard
* Create a new content request
*/
router.post('/', requireAuth, async (req, res) => {
try {
const { tmdb_id, media_type, season_number, season_numbers } = req.body;
const userId = req.user.userId;
const profileId = getProfileId(req);
if (!tmdb_id || !media_type) {
return res.status(400).json({ error: 'tmdb_id and media_type are required' });
}
if (!profileId) {
return res.status(400).json({ error: 'Profile ID is required' });
}
const isOwner = await verifyProfileOwnership(userId, req.user.userType, profileId);
if (!isOwner) {
return res.status(403).json({ error: 'Ce profil ne vous appartient pas' });
}
// Check Limits
const { remaining, limit } = await checkLimit(userId, req.user.userType, profileId, req);
if (remaining <= 0) {
return res.status(429).json({
error: `Limite atteinte. Vous avez droit à ${limit} demande(s) toutes les 48h.`,
limit_reached: true
});
}
// Handle both old single season_number and new season_numbers array
// Database column is INTEGER, so we store a single season number
let seasonParam = null;
if (season_numbers && Array.isArray(season_numbers) && season_numbers.length > 0) {
// If array has one element, store just that number
// If array has multiple elements, store the first one (or 0 for "all seasons")
if (season_numbers.length === 1) {
seasonParam = parseInt(season_numbers[0], 10);
} else {
// Multiple seasons requested - store 0 to indicate "all seasons" or first season
seasonParam = season_numbers.includes(0) ? 0 : parseInt(season_numbers[0], 10);
}
} else if (season_number !== undefined && season_number !== null) {
// Backwards compatibility: single number
seasonParam = parseInt(season_number, 10);
}
// Check if request already exists (check for exact match)
let existingQuery;
let existingParams;
if (seasonParam === null) {
existingQuery = 'SELECT id FROM wishboard_requests WHERE tmdb_id = ? AND media_type = ? AND season_number IS NULL';
existingParams = [tmdb_id, media_type];
} else {
existingQuery = 'SELECT id FROM wishboard_requests WHERE tmdb_id = ? AND media_type = ? AND season_number = ?';
existingParams = [tmdb_id, media_type, seasonParam];
}
const [existing] = await mysqlPool.execute(existingQuery, existingParams);
if (existing.length > 0) {
return res.status(409).json({
error: 'Cette demande existe déjà',
existing_id: existing[0].id
});
}
// Insert new request
const [result] = await mysqlPool.execute(
'INSERT INTO wishboard_requests (user_id, profile_id, tmdb_id, media_type, season_number, status, vote_count) VALUES (?, ?, ?, ?, ?, ?, ?)',
[userId, profileId, tmdb_id, media_type, seasonParam, 'pending', 0]
);
const insertId = result.insertId;
// Record initial status in history
await recordStatusHistory(insertId, 'pending');
res.status(201).json({
id: insertId,
message: 'Demande créée avec succès'
});
} catch (error) {
console.error('Error creating wishboard request:', error);
res.status(500).json({ error: 'Failed to create request' });
}
});
/**
* POST /api/wishboard/:id/vote
* Vote for a request
*/
router.post('/:id/vote', requireAuth, async (req, res) => {
try {
if (TURNSTILE_INVISIBLE_SECRETKEY) {
const { turnstileToken } = req.body || {};
const check = await verifyTurnstileFromRequest(req, turnstileToken, TURNSTILE_INVISIBLE_SECRETKEY);
if (!check.valid) {
return res.status(check.status).json({ error: check.error });
}
}
const requestId = req.params.id;
const userId = req.user.userId;
const profileId = getProfileId(req);
if (!profileId) {
return res.status(400).json({ error: 'Profile ID is required' });
}
const isOwner = await verifyProfileOwnership(userId, req.user.userType, profileId);
if (!isOwner) {
return res.status(403).json({ error: 'Ce profil ne vous appartient pas' });
}
// Check if already voted (dedup by user_id to prevent vote stuffing)
const [existing] = await mysqlPool.execute(
'SELECT id FROM wishboard_votes WHERE request_id = ? AND user_id = ?',
[requestId, userId]
);
if (existing.length > 0) {
return res.status(409).json({ error: 'Vous avez déjà voté pour cette demande' });
}
// Add vote
await mysqlPool.execute(
'INSERT INTO wishboard_votes (request_id, user_id, profile_id) VALUES (?, ?, ?)',
[requestId, userId, profileId]
);
res.json({ message: 'Vote enregistré' });
} catch (error) {
console.error('Error voting:', error);
res.status(500).json({ error: 'Failed to vote' });
}
});
/**
* DELETE /api/wishboard/:id/vote
* Remove vote from a request
*/
router.delete('/:id/vote', requireAuth, async (req, res) => {
try {
if (TURNSTILE_INVISIBLE_SECRETKEY) {
const { turnstileToken } = req.body || {};
const check = await verifyTurnstileFromRequest(req, turnstileToken, TURNSTILE_INVISIBLE_SECRETKEY);
if (!check.valid) {
return res.status(check.status).json({ error: check.error });
}
}
const requestId = req.params.id;
const userId = req.user.userId;
await mysqlPool.execute(
'DELETE FROM wishboard_votes WHERE request_id = ? AND user_id = ?',
[requestId, userId]
);
res.json({ message: 'Vote retiré' });
} catch (error) {
console.error('Error removing vote:', error);
res.status(500).json({ error: 'Failed to remove vote' });
}
});
/**
* GET /api/wishboard/user/requests
* Get current user's requests
*/
router.get('/user/requests', requireAuth, async (req, res) => {
try {
const profileId = getProfileId(req);
if (!profileId) {
return res.status(400).json({ error: 'Profile ID is required' });
}
const isOwner = await verifyProfileOwnership(req.user.userId, req.user.userType, profileId);
if (!isOwner) {
return res.status(403).json({ error: 'Ce profil ne vous appartient pas' });
}
const [requests] = await mysqlPool.execute(
'SELECT * FROM wishboard_requests WHERE profile_id = ? ORDER BY created_at DESC',
[profileId]
);
// Get public notes and vote status for each request
const requestsWithNotes = await Promise.all(requests.map(async (r) => {
const [notes] = await mysqlPool.execute(
'SELECT id, note, is_public, created_at FROM wishboard_notes WHERE request_id = ? AND is_public = 1 ORDER BY created_at DESC',
[r.id]
);
const [voteRows] = await mysqlPool.execute(
'SELECT id FROM wishboard_votes WHERE request_id = ? AND profile_id = ?',
[r.id, profileId]
);
// Get status history
const [statusHistory] = await mysqlPool.execute(
'SELECT status, reason, changed_at FROM wishboard_status_history WHERE request_id = ? ORDER BY changed_at ASC',
[r.id]
);
return { ...r, notes, has_voted: voteRows.length > 0, statusHistory };
}));
res.json({ requests: requestsWithNotes });
} catch (error) {
console.error('Error fetching user requests:', error);
res.status(500).json({ error: 'Failed to fetch requests' });
}
});
/**
* GET /api/wishboard/user/votes
* Get current user's voted requests
*/
router.get('/user/votes', requireAuth, async (req, res) => {
try {
const profileId = getProfileId(req);
if (!profileId) {
return res.status(400).json({ error: 'Profile ID is required' });
}
const isOwner = await verifyProfileOwnership(req.user.userId, req.user.userType, profileId);
if (!isOwner) {
return res.status(403).json({ error: 'Ce profil ne vous appartient pas' });
}
const [requests] = await mysqlPool.execute(
`SELECT r.* FROM wishboard_requests r
INNER JOIN wishboard_votes v ON v.request_id = r.id
WHERE v.profile_id = ? AND r.profile_id != ?
ORDER BY v.created_at DESC`,
[profileId, profileId]
);
const requestsWithVoted = await Promise.all(requests.map(async (r) => {
// Get status history
const [statusHistory] = await mysqlPool.execute(
'SELECT status, reason, changed_at FROM wishboard_status_history WHERE request_id = ? ORDER BY changed_at ASC',
[r.id]
);
return { ...r, has_voted: true, statusHistory };
}));
res.json({ requests: requestsWithVoted });
} catch (error) {
console.error('Error fetching voted requests:', error);
res.status(500).json({ error: 'Failed to fetch votes' });
}
});
// =====================================
// LIMITS ENDPOINT
// =====================================
/**
* GET /api/wishboard/limits
* Get user's request limit status (quota)
*/
router.get('/limits', requireAuth, async (req, res) => {
try {
const profileId = getProfileId(req);
if (!profileId) {
return res.status(400).json({ error: 'Profile ID is required' });
}
const isOwner = await verifyProfileOwnership(req.user.userId, req.user.userType, profileId);
if (!isOwner) {
return res.status(403).json({ error: 'Ce profil ne vous appartient pas' });
}
const limitStatus = await checkLimit(req.user.userId, req.user.userType, profileId, req);
res.json(limitStatus);
} catch (error) {
console.error('Error fetching limits:', error);
res.status(500).json({ error: 'Failed to fetch limits' });
}
});
// =====================================
// ADMIN ENDPOINTS
// =====================================
/**
* GET /api/wishboard/admin
* Admin list with all details
*/
router.get('/admin', requireAuth, requireUploaderOrAdmin, async (req, res) => {
try {
const { search, media_type, status, vip_only, page = '1', limit = '50' } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.min(100, Math.max(1, parseInt(limit) || 50));
const offset = (pageNum - 1) * limitNum;
let whereConditions = ['1=1'];
let whereParams = [];
if (search) {
const isNumericSearch = /^\d+$/.test(search.trim());
if (isNumericSearch) {
// Direct TMDB ID or request ID search
const numSearch = parseInt(search);
whereConditions.push('(tmdb_id = ? OR id = ?)');
whereParams.push(numSearch, numSearch);
} else {
// Text search: query TMDB API to find matching IDs
const tmdbIds = await searchTmdbIds(search.trim());
if (tmdbIds.length > 0) {
whereConditions.push(`tmdb_id IN (${tmdbIds.map(() => '?').join(',')})`);
whereParams.push(...tmdbIds);
} else {
// No TMDB results, return empty
return res.json({ requests: [], stats: { total: 0, pending: 0, added: 0, rejected: 0, not_found: 0 }, hasMore: false });
}
}
}
if (media_type && media_type !== 'all') {
whereConditions.push('media_type = ?');
whereParams.push(media_type);
}
if (status && status !== 'all') {
whereConditions.push('status = ?');
whereParams.push(status);
}
const whereClause = whereConditions.join(' AND ');
// Get requests with pagination (fetch one extra to check hasMore)
const [requests] = await mysqlPool.execute(
`SELECT * FROM wishboard_requests WHERE ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[...whereParams, String(limitNum + 1), String(offset)]
);
const hasMore = requests.length > limitNum;
const paginatedRequests = hasMore ? requests.slice(0, limitNum) : requests;
// Get notes for each request and user info
const requestsWithNotes = await Promise.all(paginatedRequests.map(async (r) => {
const [notes] = await mysqlPool.execute(
'SELECT id, admin_id, note, is_public, created_at FROM wishboard_notes WHERE request_id = ? ORDER BY created_at DESC',
[r.id]
);
// Get status history
const [statusHistory] = await mysqlPool.execute(
'SELECT id, status, reason, changed_at FROM wishboard_status_history WHERE request_id = ? ORDER BY changed_at ASC',
[r.id]
);
// Get user info
let user = { username: 'Inconnu', isVip: false };
try {
// Determine user type (bip39 or oauth)
let userType = 'oauth'; // Default to oauth
try {
await fs.access(path.join(__dirname, 'data', 'users', `bip39-${r.user_id}.json`));
userType = 'bip39';
} catch {
// Keep oauth
}
user = await getUserData(r.user_id, userType, r.profile_id);
} catch (e) {
// Ignore error, keep default
}
return { ...r, notes, statusHistory, user };
}));
// Filter VIP-only after fetching user data
const filteredRequests = vip_only === 'true'
? requestsWithNotes.filter(r => r.user?.isVip)
: requestsWithNotes;
// Get stats
const [statsResult] = await mysqlPool.execute(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'added' THEN 1 ELSE 0 END) as added,
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected,
SUM(CASE WHEN status IN ('not_found', 'not_found_recent') THEN 1 ELSE 0 END) as not_found
FROM wishboard_requests
`);
res.json({
requests: filteredRequests,
hasMore,
stats: {
total: statsResult[0].total || 0,
pending: statsResult[0].pending || 0,
added: statsResult[0].added || 0,
rejected: statsResult[0].rejected || 0,
not_found: statsResult[0].not_found || 0
}
});
} catch (error) {
console.error('Error fetching admin wishboard:', error);
res.status(500).json({ error: 'Failed to fetch admin wishboard' });
}
});
/**
* PUT /api/wishboard/admin/:id/status
* Update request status
*/
router.put('/admin/:id/status', requireAuth, requireUploaderOrAdmin, async (req, res) => {
try {
const requestId = req.params.id;
const { status, reason } = req.body;
const validStatuses = ['pending', 'not_found', 'not_found_recent', 'searching', 'added', 'rejected'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: 'Invalid status' });
}
// Get admin info from middleware
const adminId = req.admin ? req.admin.userId : null;
const adminAuthType = req.admin ? (req.admin.userType === 'bip39' ? 'bip-39' : req.admin.userType) : null;
await mysqlPool.execute(
'UPDATE wishboard_requests SET status = ?, updated_at = NOW() WHERE id = ?',
[status, requestId]
);
// Record status history with reason and admin info
const historyId = await recordStatusHistory(requestId, status, reason, adminId, adminAuthType);
res.json({ message: 'Statut mis à jour', historyId });
} catch (error) {
console.error('Error updating status:', error);
res.status(500).json({ error: 'Failed to update status' });
}
});
/**
* PUT /api/wishboard/admin/history/:historyId
* Update status history reason
*/
router.put('/admin/history/:historyId', requireAuth, requireUploaderOrAdmin, async (req, res) => {
try {
const historyId = req.params.historyId;
const { reason } = req.body;
await mysqlPool.execute(
'UPDATE wishboard_status_history SET reason = ? WHERE id = ?',
[reason, historyId]
);
res.json({ message: 'Raison mise à jour' });
} catch (error) {
console.error('Error updating history reason:', error);
res.status(500).json({ error: 'Failed to update reason' });
}
});
/**
* POST /api/wishboard/admin/:id/notes
* Add note to request
*/
router.post('/admin/:id/notes', requireAuth, requireUploaderOrAdmin, async (req, res) => {
try {
const requestId = req.params.id;
const { note, is_public = true } = req.body;
const adminId = req.user.userId;
if (!note || !note.trim()) {
return res.status(400).json({ error: 'Note content is required' });
}
// Check if note already exists for this request
const [existing] = await mysqlPool.execute('SELECT id FROM wishboard_notes WHERE request_id = ?', [requestId]);
let resultId;
if (existing.length > 0) {
// Update existing
await mysqlPool.execute(
'UPDATE wishboard_notes SET note = ?, is_public = ?, admin_id = ?, updated_at = NOW() WHERE request_id = ?',
[note.trim(), is_public ? 1 : 0, adminId, requestId]
);
resultId = existing[0].id;
} else {
// Insert new
const [result] = await mysqlPool.execute(
'INSERT INTO wishboard_notes (request_id, admin_id, note, is_public) VALUES (?, ?, ?, ?)',
[requestId, adminId, note.trim(), is_public ? 1 : 0]
);
resultId = result.insertId;
}
res.status(201).json({
id: resultId,
admin_id: adminId,
note: note.trim(),
is_public: is_public ? 1 : 0,
created_at: new Date().toISOString()
});
} catch (error) {
console.error('Error adding note:', error);
res.status(500).json({ error: 'Failed to add note' });
}
});
/**
* GET /api/wishboard/admin/leaderboard
* Get monthly leaderboard of admins/uploaders by greenlight count
*/
router.get('/admin/leaderboard', requireAuth, requireUploaderOrAdmin, async (req, res) => {
try {
const monthParam = Array.isArray(req.query.month) ? req.query.month[0] : req.query.month;
const monthMatch = typeof monthParam === 'string' && monthParam.trim()
? monthParam.trim().match(/^(\d{4})-(\d{2})$/)
: null;
if (monthParam && (!monthMatch || Number(monthMatch[2]) < 1 || Number(monthMatch[2]) > 12)) {
return res.status(400).json({ error: 'Invalid month format. Expected YYYY-MM' });
}
const targetDate = monthMatch
? new Date(Number(monthMatch[1]), Number(monthMatch[2]) - 1, 1)
: new Date();
const periodStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), 1);
const periodEnd = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 1);
const formatSqlDate = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
// Get all admins/uploaders who have greenlighted at least one request for the selected month
const [rows] = await mysqlPool.execute(`
SELECT
h.admin_id,
h.admin_auth_type,
COUNT(*) as greenlight_count,
MAX(h.changed_at) as last_greenlight_at
FROM wishboard_status_history h
WHERE h.status = 'added'
AND h.admin_id IS NOT NULL
AND h.changed_at >= ?
AND h.changed_at < ?
GROUP BY h.admin_id, h.admin_auth_type
ORDER BY greenlight_count DESC, last_greenlight_at DESC
`, [formatSqlDate(periodStart), formatSqlDate(periodEnd)]);
// Get admin roles from admins table
const adminIds = rows.map(r => r.admin_id);
let adminRoles = {};
if (adminIds.length > 0) {
const placeholders = adminIds.map(() => '?').join(',');
const [roleRows] = await mysqlPool.execute(
`SELECT user_id, role FROM admins WHERE user_id IN (${placeholders})`,
adminIds
);
for (const r of roleRows) {
adminRoles[r.user_id] = r.role || 'admin';
}
}
// Resolve user data (username, avatar) for each greenlighter via the
// shared helper. Greenlight uses the user's FIRST Movix profile
// (preferProfile) for pdp + username, not the OAuth/Discord identity.
const leaderboard = await Promise.all(rows.map(async (row) => {
const identity = await resolveAdminIdentity(row.admin_id, row.admin_auth_type, { preferProfile: true });
return {
admin_id: row.admin_id,
admin_auth_type: row.admin_auth_type,
role: adminRoles[row.admin_id] || 'admin',
username: identity.username,
avatar: identity.avatar,
greenlight_count: row.greenlight_count,
last_greenlight_at: row.last_greenlight_at
};
}));
res.json({
leaderboard,
month: `${periodStart.getFullYear()}-${String(periodStart.getMonth() + 1).padStart(2, '0')}`
});
} catch (error) {
console.error('Error fetching leaderboard:', error);
res.status(500).json({ error: 'Failed to fetch leaderboard' });
}
});
/**
* DELETE /api/wishboard/admin/:id
* Delete a request
*/
router.delete('/admin/:id', requireAuth, requireUploaderOrAdmin, async (req, res) => {
try {
const requestId = req.params.id;
// Delete related votes first
await mysqlPool.execute('DELETE FROM wishboard_votes WHERE request_id = ?', [requestId]);
// Delete related notes
await mysqlPool.execute('DELETE FROM wishboard_notes WHERE request_id = ?', [requestId]);
// Delete request
await mysqlPool.execute('DELETE FROM wishboard_requests WHERE id = ?', [requestId]);
res.json({ message: 'Demande supprimée' });
} catch (error) {
console.error('Error deleting request:', error);
res.status(500).json({ error: 'Failed to delete request' });
}
});
return router;
}
module.exports = { createWishboardRouter };