mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 16:32:07 +00:00
157 lines
4.5 KiB
JavaScript
157 lines
4.5 KiB
JavaScript
const express = require('express');
|
|
const crypto = require('crypto');
|
|
const rateLimit = require('express-rate-limit');
|
|
const { ipKeyGenerator } = require('express-rate-limit');
|
|
const router = express.Router();
|
|
const { getPool } = require('../mysqlPool');
|
|
const { verifyTurnstileFromRequest } = require('../utils/turnstile');
|
|
const { isAdmin } = require('../middleware/auth');
|
|
const { createRedisRateLimitStore } = require('../utils/redisRateLimitStore');
|
|
|
|
// Whitelist of valid tuto slugs. Must stay in sync with
|
|
// src/pages/help/tutoRegistry.tsx TUTO_REGISTRY entries.
|
|
const VALID_SLUGS = new Set([
|
|
'ca-marche-plus',
|
|
'dns',
|
|
'changer-lecteur',
|
|
'qualite-video',
|
|
'sous-titres',
|
|
'chromecast',
|
|
'compte',
|
|
'recuperer-compte',
|
|
'lier-compte',
|
|
'profils',
|
|
'watchparty',
|
|
'listes-partagees',
|
|
'installer-pwa',
|
|
'extension',
|
|
'premid',
|
|
'app-mobile',
|
|
'open-source',
|
|
'vip',
|
|
'debrid',
|
|
'telechargement',
|
|
'miroirs',
|
|
'live-tv',
|
|
'extraction',
|
|
'priorite-sources',
|
|
'dernier-lecteur',
|
|
'apparence',
|
|
]);
|
|
|
|
const IP_SALT =
|
|
process.env.TURNSTILE_IP_SALT ||
|
|
process.env.JWT_SECRET ||
|
|
'movix-help-feedback-default-salt';
|
|
|
|
function hashIp(req) {
|
|
const ip =
|
|
req.headers['cf-connecting-ip'] ||
|
|
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
|
req.ip ||
|
|
'unknown';
|
|
return crypto.createHash('sha256').update(`${IP_SALT}:${ip}`).digest('hex');
|
|
}
|
|
|
|
const feedbackRateLimit = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 30,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
keyGenerator: (req) =>
|
|
req.headers['cf-connecting-ip'] ||
|
|
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
|
ipKeyGenerator(req.ip),
|
|
store: createRedisRateLimitStore({ prefix: 'rate-limit:help-feedback:' }),
|
|
passOnStoreError: true,
|
|
validate: { xForwardedForHeader: false, ip: false },
|
|
message: { error: 'Trop de votes. Réessaie dans quelques minutes.' },
|
|
});
|
|
|
|
/**
|
|
* POST /api/help/feedback
|
|
* body: { slug: string, helpful: boolean, turnstileToken: string }
|
|
*
|
|
* Upserts a vote keyed on (slug, ip_hash) — same user can change their mind
|
|
* but we don't count duplicates.
|
|
*/
|
|
router.post('/feedback', feedbackRateLimit, async (req, res) => {
|
|
try {
|
|
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
const { slug, helpful, turnstileToken } = body;
|
|
|
|
if (typeof slug !== 'string' || !VALID_SLUGS.has(slug)) {
|
|
return res.status(400).json({ error: 'Invalid slug' });
|
|
}
|
|
if (typeof helpful !== 'boolean') {
|
|
return res.status(400).json({ error: 'Invalid helpful value' });
|
|
}
|
|
if (turnstileToken !== undefined && typeof turnstileToken !== 'string') {
|
|
return res.status(400).json({ error: 'Invalid turnstile token' });
|
|
}
|
|
|
|
const turnstileCheck = await verifyTurnstileFromRequest(req, turnstileToken);
|
|
if (!turnstileCheck.valid) {
|
|
return res.status(turnstileCheck.status || 403).json({
|
|
error: turnstileCheck.error || 'Turnstile verification failed',
|
|
});
|
|
}
|
|
|
|
const ipHash = hashIp(req);
|
|
const userAgent = String(req.headers['user-agent'] || '').slice(0, 255);
|
|
const helpfulInt = helpful ? 1 : 0;
|
|
|
|
const pool = getPool();
|
|
await pool.execute(
|
|
`INSERT INTO help_feedback (slug, helpful, ip_hash, user_agent)
|
|
VALUES (?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE helpful = VALUES(helpful), user_agent = VALUES(user_agent)`,
|
|
[slug, helpfulInt, ipHash, userAgent],
|
|
);
|
|
|
|
return res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error('[helpFeedback] submit error:', err);
|
|
return res.status(500).json({ error: 'Internal error' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/help/feedback/stats
|
|
* Admin-only. Returns aggregated stats per slug: { slug, up, down, total, ratio }.
|
|
*/
|
|
router.get('/feedback/stats', isAdmin, async (_req, res) => {
|
|
try {
|
|
const pool = getPool();
|
|
const [rows] = await pool.execute(
|
|
`SELECT
|
|
slug,
|
|
SUM(CASE WHEN helpful = 1 THEN 1 ELSE 0 END) AS up,
|
|
SUM(CASE WHEN helpful = 0 THEN 1 ELSE 0 END) AS down,
|
|
COUNT(*) AS total
|
|
FROM help_feedback
|
|
GROUP BY slug
|
|
ORDER BY total DESC`,
|
|
);
|
|
|
|
const stats = rows.map((r) => {
|
|
const up = Number(r.up) || 0;
|
|
const down = Number(r.down) || 0;
|
|
const total = Number(r.total) || 0;
|
|
return {
|
|
slug: r.slug,
|
|
up,
|
|
down,
|
|
total,
|
|
ratio: total > 0 ? up / total : 0,
|
|
};
|
|
});
|
|
|
|
return res.json({ stats });
|
|
} catch (err) {
|
|
console.error('[helpFeedback] stats error:', err);
|
|
return res.status(500).json({ error: 'Internal error' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|