Ajout système d'authentification complet avec interface de connexion
Backend: - auth.js: Système de tokens avec API keys UUID - rateLimiter.js: Rate limiting multi-tiers (global, traduction, admin) - logger.js: Logging des requêtes avec rotation automatique - adminRoutes.js: Routes admin pour gestion des tokens - server.js: Intégration de tous les middlewares de sécurité Frontend: - Interface de connexion modale élégante - Stockage sécurisé API key dans localStorage - Bouton déconnexion dans le header - authFetch() wrapper pour toutes les requêtes protégées - Protection automatique des endpoints de traduction Sécurité: - Token admin généré automatiquement au premier lancement - Limites quotidiennes par token configurables - Rate limiting pour prévenir les abus - Logs détaillés de toutes les requêtes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5ad89885fc
commit
3cd73e6598
14
ConfluentTranslator/.env.example
Normal file
14
ConfluentTranslator/.env.example
Normal file
@ -0,0 +1,14 @@
|
||||
# ConfluentTranslator Configuration
|
||||
|
||||
# Server
|
||||
PORT=3000
|
||||
|
||||
# API Keys (LLM)
|
||||
ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||
OPENAI_API_KEY=sk-your-key-here
|
||||
|
||||
# Security (optionnel - utilisé pour JWT, peut être généré aléatoirement)
|
||||
JWT_SECRET=changez-ce-secret-en-production
|
||||
|
||||
# Note: Les API keys pour le traducteur (authentication) sont gérées dans data/tokens.json
|
||||
# Le token admin sera automatiquement créé au premier lancement et affiché dans les logs
|
||||
95
ConfluentTranslator/adminRoutes.js
Normal file
95
ConfluentTranslator/adminRoutes.js
Normal file
@ -0,0 +1,95 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { requireAdmin, createToken, listTokens, disableToken, enableToken, deleteToken, getGlobalStats } = require('./auth');
|
||||
const { getLogs, getLogStats } = require('./logger');
|
||||
const { adminLimiter } = require('./rateLimiter');
|
||||
|
||||
// Appliquer l'auth et rate limiting à toutes les routes admin
|
||||
router.use(requireAdmin);
|
||||
router.use(adminLimiter);
|
||||
|
||||
// Liste des tokens
|
||||
router.get('/tokens', (req, res) => {
|
||||
const tokens = listTokens();
|
||||
res.json({ tokens });
|
||||
});
|
||||
|
||||
// Créer un nouveau token
|
||||
router.post('/tokens', (req, res) => {
|
||||
const { name, role = 'user', dailyLimit = 100 } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Missing parameter: name' });
|
||||
}
|
||||
|
||||
const token = createToken(name, role, dailyLimit);
|
||||
res.json({
|
||||
success: true,
|
||||
token: {
|
||||
...token,
|
||||
apiKey: token.apiKey // Retourner la clé complète seulement à la création
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Désactiver un token
|
||||
router.post('/tokens/:id/disable', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const success = disableToken(id);
|
||||
|
||||
if (success) {
|
||||
res.json({ success: true, message: 'Token disabled' });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Token not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// Réactiver un token
|
||||
router.post('/tokens/:id/enable', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const success = enableToken(id);
|
||||
|
||||
if (success) {
|
||||
res.json({ success: true, message: 'Token enabled' });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Token not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// Supprimer un token
|
||||
router.delete('/tokens/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const success = deleteToken(id);
|
||||
|
||||
if (success) {
|
||||
res.json({ success: true, message: 'Token deleted' });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Token not found or cannot be deleted' });
|
||||
}
|
||||
});
|
||||
|
||||
// Stats globales
|
||||
router.get('/stats', (req, res) => {
|
||||
const tokenStats = getGlobalStats();
|
||||
const logStats = getLogStats();
|
||||
|
||||
res.json({
|
||||
tokens: tokenStats,
|
||||
logs: logStats
|
||||
});
|
||||
});
|
||||
|
||||
// Logs
|
||||
router.get('/logs', (req, res) => {
|
||||
const { limit = 100, user, path, statusCode } = req.query;
|
||||
|
||||
const filter = {};
|
||||
if (user) filter.user = user;
|
||||
if (path) filter.path = path;
|
||||
if (statusCode) filter.statusCode = parseInt(statusCode);
|
||||
|
||||
const logs = getLogs(parseInt(limit), filter);
|
||||
res.json({ logs });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
206
ConfluentTranslator/auth.js
Normal file
206
ConfluentTranslator/auth.js
Normal file
@ -0,0 +1,206 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const TOKENS_FILE = path.join(__dirname, 'data', 'tokens.json');
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'confluent-secret-key-change-in-production';
|
||||
|
||||
// Structure des tokens
|
||||
let tokens = {};
|
||||
|
||||
function loadTokens() {
|
||||
try {
|
||||
const dataDir = path.join(__dirname, 'data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(TOKENS_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading tokens:', error);
|
||||
}
|
||||
|
||||
// Default: créer un token admin si aucun token n'existe
|
||||
const defaultTokens = {
|
||||
admin: {
|
||||
id: 'admin',
|
||||
name: 'Admin',
|
||||
role: 'admin',
|
||||
apiKey: uuidv4(),
|
||||
createdAt: new Date().toISOString(),
|
||||
active: true,
|
||||
requestsToday: 0,
|
||||
dailyLimit: -1 // illimité
|
||||
}
|
||||
};
|
||||
|
||||
saveTokens(defaultTokens);
|
||||
console.log('🔑 Token admin créé:', defaultTokens.admin.apiKey);
|
||||
return defaultTokens;
|
||||
}
|
||||
|
||||
function saveTokens() {
|
||||
try {
|
||||
fs.writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Error saving tokens:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware d'authentification
|
||||
function authenticate(req, res, next) {
|
||||
// Routes publiques (GET seulement)
|
||||
const publicRoutes = ['/api/lexique', '/api/stats', '/lexique'];
|
||||
if (req.method === 'GET' && publicRoutes.some(route => req.path.startsWith(route))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const apiKey = req.headers['x-api-key'] || req.query.apiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ error: 'API key required' });
|
||||
}
|
||||
|
||||
// Chercher le token
|
||||
const token = Object.values(tokens).find(t => t.apiKey === apiKey);
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Invalid API key' });
|
||||
}
|
||||
|
||||
if (!token.active) {
|
||||
return res.status(403).json({ error: 'Token disabled' });
|
||||
}
|
||||
|
||||
// Vérifier la limite quotidienne
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const tokenToday = token.lastUsed?.split('T')[0];
|
||||
|
||||
if (tokenToday !== today) {
|
||||
token.requestsToday = 0;
|
||||
}
|
||||
|
||||
if (token.dailyLimit > 0 && token.requestsToday >= token.dailyLimit) {
|
||||
return res.status(429).json({ error: 'Daily limit reached' });
|
||||
}
|
||||
|
||||
// Mettre à jour les stats
|
||||
token.requestsToday++;
|
||||
token.lastUsed = new Date().toISOString();
|
||||
saveTokens();
|
||||
|
||||
// Ajouter les infos au req
|
||||
req.user = {
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
role: token.role
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Middleware admin uniquement
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Créer un nouveau token
|
||||
function createToken(name, role = 'user', dailyLimit = 100) {
|
||||
const id = uuidv4();
|
||||
const apiKey = uuidv4();
|
||||
|
||||
tokens[id] = {
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
apiKey,
|
||||
createdAt: new Date().toISOString(),
|
||||
active: true,
|
||||
requestsToday: 0,
|
||||
dailyLimit
|
||||
};
|
||||
|
||||
saveTokens();
|
||||
return tokens[id];
|
||||
}
|
||||
|
||||
// Lister tous les tokens
|
||||
function listTokens() {
|
||||
return Object.values(tokens).map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
role: t.role,
|
||||
apiKey: t.apiKey.substring(0, 8) + '...',
|
||||
createdAt: t.createdAt,
|
||||
active: t.active,
|
||||
requestsToday: t.requestsToday,
|
||||
dailyLimit: t.dailyLimit,
|
||||
lastUsed: t.lastUsed
|
||||
}));
|
||||
}
|
||||
|
||||
// Désactiver un token
|
||||
function disableToken(id) {
|
||||
if (tokens[id]) {
|
||||
tokens[id].active = false;
|
||||
saveTokens();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Réactiver un token
|
||||
function enableToken(id) {
|
||||
if (tokens[id]) {
|
||||
tokens[id].active = true;
|
||||
saveTokens();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Supprimer un token
|
||||
function deleteToken(id) {
|
||||
if (id === 'admin') {
|
||||
return false; // Ne pas supprimer l'admin
|
||||
}
|
||||
if (tokens[id]) {
|
||||
delete tokens[id];
|
||||
saveTokens();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stats globales
|
||||
function getGlobalStats() {
|
||||
const tokenList = Object.values(tokens);
|
||||
return {
|
||||
totalTokens: tokenList.length,
|
||||
activeTokens: tokenList.filter(t => t.active).length,
|
||||
totalRequestsToday: tokenList.reduce((sum, t) => sum + t.requestsToday, 0)
|
||||
};
|
||||
}
|
||||
|
||||
// Charger les tokens au démarrage
|
||||
tokens = loadTokens();
|
||||
|
||||
module.exports = {
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
createToken,
|
||||
listTokens,
|
||||
disableToken,
|
||||
enableToken,
|
||||
deleteToken,
|
||||
getGlobalStats,
|
||||
loadTokens,
|
||||
tokens
|
||||
};
|
||||
1
ConfluentTranslator/data/tokens.json
Normal file
1
ConfluentTranslator/data/tokens.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
151
ConfluentTranslator/logger.js
Normal file
151
ConfluentTranslator/logger.js
Normal file
@ -0,0 +1,151 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOGS_DIR = path.join(__dirname, 'logs');
|
||||
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
// Créer le dossier logs s'il n'existe pas
|
||||
if (!fs.existsSync(LOGS_DIR)) {
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function getLogFile() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return path.join(LOGS_DIR, `requests-${today}.log`);
|
||||
}
|
||||
|
||||
function log(type, data) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
type,
|
||||
...data
|
||||
};
|
||||
|
||||
const logFile = getLogFile();
|
||||
const logLine = JSON.stringify(logEntry) + '\n';
|
||||
|
||||
fs.appendFileSync(logFile, logLine);
|
||||
|
||||
// Rotation si le fichier devient trop gros
|
||||
try {
|
||||
const stats = fs.statSync(logFile);
|
||||
if (stats.size > MAX_LOG_SIZE) {
|
||||
const archiveName = logFile.replace('.log', `-${Date.now()}.log`);
|
||||
fs.renameSync(logFile, archiveName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rotating log file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware de logging
|
||||
function requestLogger(req, res, next) {
|
||||
const start = Date.now();
|
||||
|
||||
// Capturer la réponse
|
||||
const originalSend = res.send;
|
||||
res.send = function (data) {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
log('request', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
ip: req.ip || req.connection.remoteAddress,
|
||||
user: req.user?.name || 'anonymous',
|
||||
userId: req.user?.id || null,
|
||||
statusCode: res.statusCode,
|
||||
duration,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
|
||||
originalSend.apply(res, arguments);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Lire les logs
|
||||
function getLogs(limit = 100, filter = {}) {
|
||||
const logFile = getLogFile();
|
||||
|
||||
if (!fs.existsSync(logFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const logs = fs.readFileSync(logFile, 'utf8')
|
||||
.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(log => log !== null);
|
||||
|
||||
// Appliquer les filtres
|
||||
let filtered = logs;
|
||||
|
||||
if (filter.user) {
|
||||
filtered = filtered.filter(log => log.user === filter.user);
|
||||
}
|
||||
|
||||
if (filter.path) {
|
||||
filtered = filtered.filter(log => log.path && log.path.includes(filter.path));
|
||||
}
|
||||
|
||||
if (filter.statusCode) {
|
||||
filtered = filtered.filter(log => log.statusCode === filter.statusCode);
|
||||
}
|
||||
|
||||
// Retourner les derniers logs
|
||||
return filtered.slice(-limit).reverse();
|
||||
}
|
||||
|
||||
// Stats des logs
|
||||
function getLogStats() {
|
||||
const logs = getLogs(1000);
|
||||
|
||||
const stats = {
|
||||
totalRequests: logs.length,
|
||||
byUser: {},
|
||||
byPath: {},
|
||||
byStatus: {},
|
||||
avgDuration: 0,
|
||||
errors: 0
|
||||
};
|
||||
|
||||
let totalDuration = 0;
|
||||
|
||||
logs.forEach(log => {
|
||||
// Par utilisateur
|
||||
stats.byUser[log.user] = (stats.byUser[log.user] || 0) + 1;
|
||||
|
||||
// Par path
|
||||
stats.byPath[log.path] = (stats.byPath[log.path] || 0) + 1;
|
||||
|
||||
// Par status
|
||||
stats.byStatus[log.statusCode] = (stats.byStatus[log.statusCode] || 0) + 1;
|
||||
|
||||
// Durée
|
||||
totalDuration += log.duration || 0;
|
||||
|
||||
// Erreurs
|
||||
if (log.statusCode >= 400) {
|
||||
stats.errors++;
|
||||
}
|
||||
});
|
||||
|
||||
stats.avgDuration = logs.length > 0 ? Math.round(totalDuration / logs.length) : 0;
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
log,
|
||||
requestLogger,
|
||||
getLogs,
|
||||
getLogStats
|
||||
};
|
||||
162
ConfluentTranslator/package-lock.json
generated
162
ConfluentTranslator/package-lock.json
generated
@ -9,9 +9,13 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.71.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"openai": "^4.20.1"
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"openai": "^4.20.1",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
@ -135,6 +139,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@ -196,6 +209,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@ -377,6 +396,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@ -507,6 +535,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@ -785,6 +831,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@ -853,6 +908,97 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@ -1238,7 +1384,6 @@
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@ -1502,6 +1647,19 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
||||
@ -9,9 +9,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.71.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"openai": "^4.20.1"
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"openai": "^4.20.1",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
|
||||
@ -415,11 +415,110 @@
|
||||
color: #666;
|
||||
border-bottom-color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Login overlay */
|
||||
.login-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
.login-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
.login-modal {
|
||||
background: #2a2a2a;
|
||||
border: 2px solid #4a9eff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 10px 40px rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
.login-modal h2 {
|
||||
color: #4a9eff;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
.login-modal p {
|
||||
color: #b0b0b0;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.login-modal .form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.login-modal input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 1em;
|
||||
font-family: monospace;
|
||||
}
|
||||
.login-modal button {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.login-error {
|
||||
color: #ff4a4a;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
body.light-theme .login-modal {
|
||||
background: #ffffff;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 10px 40px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
body.light-theme .login-modal h2 {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
/* Logout button */
|
||||
.header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.logout-btn {
|
||||
background: #555;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9em;
|
||||
font-weight: normal;
|
||||
}
|
||||
.logout-btn:hover {
|
||||
background: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Login overlay -->
|
||||
<div id="login-overlay" class="login-overlay">
|
||||
<div class="login-modal">
|
||||
<h2>🔐 Connexion requise</h2>
|
||||
<p>Entrez votre clé API pour accéder au traducteur Confluent.</p>
|
||||
<div class="form-group">
|
||||
<label>API Key</label>
|
||||
<input type="password" id="login-api-key" placeholder="c32b04be-2e68-4e15-8362-...">
|
||||
</div>
|
||||
<button onclick="login()">Se connecter</button>
|
||||
<div id="login-error" class="login-error">Clé API invalide</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<h1>ConfluentTranslator</h1>
|
||||
<div class="header-container">
|
||||
<h1>ConfluentTranslator</h1>
|
||||
<button class="logout-btn" onclick="logout()">Déconnexion</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
@ -715,6 +814,106 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// === AUTHENTICATION SYSTEM ===
|
||||
|
||||
// Get API key from localStorage
|
||||
const getApiKey = () => localStorage.getItem('confluentApiKey');
|
||||
|
||||
// Set API key to localStorage
|
||||
const setApiKey = (key) => localStorage.setItem('confluentApiKey', key);
|
||||
|
||||
// Remove API key from localStorage
|
||||
const clearApiKey = () => localStorage.removeItem('confluentApiKey');
|
||||
|
||||
// Check if user is authenticated
|
||||
const checkAuth = () => {
|
||||
const apiKey = getApiKey();
|
||||
const overlay = document.getElementById('login-overlay');
|
||||
|
||||
if (!apiKey) {
|
||||
// Show login overlay
|
||||
overlay.classList.remove('hidden');
|
||||
} else {
|
||||
// Hide login overlay
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
return !!apiKey;
|
||||
};
|
||||
|
||||
// Login function
|
||||
const login = async () => {
|
||||
const apiKey = document.getElementById('login-api-key').value.trim();
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
|
||||
if (!apiKey) {
|
||||
errorDiv.textContent = 'Veuillez entrer une clé API';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Test the API key with a simple request
|
||||
try {
|
||||
const response = await fetch('/api/stats', {
|
||||
headers: {
|
||||
'x-api-key': apiKey
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Key is valid
|
||||
setApiKey(apiKey);
|
||||
checkAuth();
|
||||
errorDiv.style.display = 'none';
|
||||
document.getElementById('login-api-key').value = '';
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
// Invalid key
|
||||
errorDiv.textContent = 'Clé API invalide';
|
||||
errorDiv.style.display = 'block';
|
||||
} else {
|
||||
// Other error
|
||||
errorDiv.textContent = `Erreur: ${response.status}`;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
errorDiv.textContent = `Erreur de connexion: ${error.message}`;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
// Logout function
|
||||
const logout = () => {
|
||||
if (confirm('Êtes-vous sûr de vouloir vous déconnecter ?')) {
|
||||
clearApiKey();
|
||||
checkAuth();
|
||||
}
|
||||
};
|
||||
|
||||
// Add enter key support for login
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('login-api-key').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
login();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Authenticated fetch wrapper
|
||||
const authFetch = (url, options = {}) => {
|
||||
const apiKey = getApiKey();
|
||||
|
||||
// Merge headers
|
||||
const headers = {
|
||||
...options.headers,
|
||||
'x-api-key': apiKey
|
||||
};
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
};
|
||||
|
||||
// Lexique data
|
||||
let lexiqueData = null;
|
||||
|
||||
@ -1058,7 +1257,7 @@
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/translate', {
|
||||
const response = await authFetch('/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
@ -1159,7 +1358,7 @@
|
||||
|
||||
try {
|
||||
// Step 1: Get raw translation to show details immediately
|
||||
const rawResponse = await fetch('/api/translate/conf2fr', {
|
||||
const rawResponse = await authFetch('/api/translate/conf2fr', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@ -1213,7 +1412,7 @@
|
||||
}
|
||||
|
||||
// Step 2: Get LLM refined translation
|
||||
const llmResponse = await fetch('/api/translate/conf2fr/llm', {
|
||||
const llmResponse = await authFetch('/api/translate/conf2fr/llm', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@ -1245,6 +1444,7 @@
|
||||
});
|
||||
|
||||
// Initialize
|
||||
checkAuth(); // Check if user is logged in
|
||||
loadSettings();
|
||||
loadLexique();
|
||||
updateSettingsIndicators();
|
||||
|
||||
38
ConfluentTranslator/rateLimiter.js
Normal file
38
ConfluentTranslator/rateLimiter.js
Normal file
@ -0,0 +1,38 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
// Rate limiter global par IP
|
||||
const globalLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 200, // max 200 requêtes par IP
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests from this IP, please try again later.' }
|
||||
});
|
||||
|
||||
// Rate limiter pour les traductions (plus strict)
|
||||
const translationLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 10, // max 10 traductions par minute
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => {
|
||||
// Skip si l'utilisateur est admin
|
||||
return req.user && req.user.role === 'admin';
|
||||
},
|
||||
message: { error: 'Too many translation requests. Please wait a moment.' }
|
||||
});
|
||||
|
||||
// Rate limiter pour les endpoints sensibles (admin)
|
||||
const adminLimiter = rateLimit({
|
||||
windowMs: 5 * 60 * 1000, // 5 minutes
|
||||
max: 50,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many admin requests.' }
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
globalLimiter,
|
||||
translationLimiter,
|
||||
adminLimiter
|
||||
};
|
||||
@ -15,10 +15,18 @@ const { buildContextualPrompt, getBasePrompt, getPromptStats } = require('./prom
|
||||
const { buildReverseIndex: buildConfluentIndex } = require('./reverseIndexBuilder');
|
||||
const { translateConfluentToFrench, translateConfluentDetailed } = require('./confluentToFrench');
|
||||
|
||||
// Security modules
|
||||
const { authenticate, requireAdmin, createToken, listTokens, disableToken, enableToken, deleteToken, getGlobalStats } = require('./auth');
|
||||
const { globalLimiter, translationLimiter, adminLimiter } = require('./rateLimiter');
|
||||
const { requestLogger, getLogs, getLogStats } = require('./logger');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Middlewares
|
||||
app.use(express.json());
|
||||
app.use(requestLogger); // Log toutes les requêtes
|
||||
app.use(globalLimiter); // Rate limiting global
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Load prompts
|
||||
@ -319,7 +327,7 @@ app.post('/api/analyze/coverage', (req, res) => {
|
||||
});
|
||||
|
||||
// Translation endpoint (NOUVEAU SYSTÈME CONTEXTUEL)
|
||||
app.post('/translate', async (req, res) => {
|
||||
app.post('/translate', authenticate, translationLimiter, async (req, res) => {
|
||||
const { text, target, provider, model, temperature = 1.0, useLexique = true } = req.body;
|
||||
|
||||
if (!text || !target || !provider || !model) {
|
||||
@ -640,7 +648,7 @@ app.post('/api/translate/conf2fr', (req, res) => {
|
||||
});
|
||||
|
||||
// NEW: Confluent → French with LLM refinement
|
||||
app.post('/api/translate/conf2fr/llm', async (req, res) => {
|
||||
app.post('/api/translate/conf2fr/llm', authenticate, translationLimiter, async (req, res) => {
|
||||
const { text, variant = 'ancien', provider = 'anthropic', model = 'claude-sonnet-4-20250514' } = req.body;
|
||||
|
||||
if (!text) {
|
||||
@ -719,6 +727,10 @@ app.post('/api/translate/conf2fr/llm', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Admin routes
|
||||
const adminRoutes = require('./adminRoutes');
|
||||
app.use('/api/admin', authenticate, adminRoutes);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`ConfluentTranslator running on http://localhost:${PORT}`);
|
||||
console.log(`Loaded: ${lexiques.ancien?.meta?.total_entries || 0} ancien entries, ${lexiques.proto?.meta?.total_entries || 0} proto entries`);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user