fixes: favoritos y mas cosas
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
@@ -77,9 +77,9 @@ export async function authMiddleware(req, res, next) {
|
||||
export const basicAuthMiddleware = authMiddleware;
|
||||
|
||||
// Función para crear sesión
|
||||
export async function createSession(username) {
|
||||
export async function createSession(username, fingerprint = null, deviceInfo = null) {
|
||||
const { createSession: createSessionInDB } = await import('../services/mongodb.js');
|
||||
return await createSessionInDB(username);
|
||||
return await createSessionInDB(username, fingerprint, deviceInfo);
|
||||
}
|
||||
|
||||
// Función para invalidar sesión
|
||||
|
||||
81
web/backend/routes/admin.js
Normal file
81
web/backend/routes/admin.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import express from 'express';
|
||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||
import { getDB, getAllSessions, deleteSession, getRateLimiterInfo } from '../services/mongodb.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Obtener información del rate limiter y bloqueos (requiere admin)
|
||||
router.get('/rate-limiter', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const rateLimiterInfo = await getRateLimiterInfo();
|
||||
res.json(rateLimiterInfo);
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo información del rate limiter:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener todas las sesiones (requiere admin)
|
||||
router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const db = getDB();
|
||||
if (!db) {
|
||||
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||
}
|
||||
|
||||
const sessions = await getAllSessions();
|
||||
|
||||
// Agrupar por usuario para estadísticas
|
||||
const sessionsByUser = {};
|
||||
let activeSessions = 0;
|
||||
let expiredSessions = 0;
|
||||
|
||||
sessions.forEach(session => {
|
||||
if (!sessionsByUser[session.username]) {
|
||||
sessionsByUser[session.username] = 0;
|
||||
}
|
||||
sessionsByUser[session.username]++;
|
||||
|
||||
if (session.isExpired) {
|
||||
expiredSessions++;
|
||||
} else {
|
||||
activeSessions++;
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
sessions,
|
||||
stats: {
|
||||
total: sessions.length,
|
||||
active: activeSessions,
|
||||
expired: expiredSessions,
|
||||
byUser: sessionsByUser,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo sesiones:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Eliminar una sesión específica (requiere admin)
|
||||
router.delete('/sessions/:token', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
|
||||
const deleted = await deleteSession(token);
|
||||
|
||||
if (deleted) {
|
||||
res.json({ success: true, message: 'Sesión eliminada correctamente' });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Sesión no encontrada' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error eliminando sesión:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express from 'express';
|
||||
import { getNotifiedArticles, getArticleFacets, searchArticles } from '../services/mongodb.js';
|
||||
import { getNotifiedArticles, getArticleFacets, searchArticles, getArticle } from '../services/mongodb.js';
|
||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -25,6 +25,9 @@ router.get('/', basicAuthMiddleware, async (req, res) => {
|
||||
if (req.query.worker_name) filter.worker_name = req.query.worker_name;
|
||||
if (req.query.platform) filter.platform = req.query.platform;
|
||||
|
||||
// Siempre incluir el username del usuario autenticado para incluir su is_favorite
|
||||
filter.currentUsername = user.username;
|
||||
|
||||
const articles = await getNotifiedArticles(filter);
|
||||
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
@@ -87,6 +90,9 @@ router.get('/search', basicAuthMiddleware, async (req, res) => {
|
||||
if (req.query.worker_name) filter.worker_name = req.query.worker_name;
|
||||
if (req.query.platform) filter.platform = req.query.platform;
|
||||
|
||||
// Siempre incluir el username del usuario autenticado para incluir su is_favorite
|
||||
filter.currentUsername = user.username;
|
||||
|
||||
// Obtener modo de búsqueda (AND u OR), por defecto AND
|
||||
const searchMode = (req.query.mode || 'AND').toUpperCase();
|
||||
|
||||
@@ -104,5 +110,39 @@ router.get('/search', basicAuthMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener un artículo específico por plataforma e ID (requiere autenticación obligatoria)
|
||||
router.get('/:platform/:id', basicAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { platform, id } = req.params;
|
||||
|
||||
// Obtener usuario autenticado (requerido)
|
||||
const user = req.user;
|
||||
const isAdmin = user.role === 'admin';
|
||||
|
||||
// Obtener el artículo con is_favorite del usuario autenticado
|
||||
const article = await getArticle(platform, id, user.username);
|
||||
|
||||
if (!article) {
|
||||
return res.status(404).json({ error: 'Artículo no encontrado' });
|
||||
}
|
||||
|
||||
// Si no es admin, verificar que el artículo pertenezca al usuario
|
||||
// Verificar en user_info si el artículo tiene alguna relación con el usuario
|
||||
if (!isAdmin) {
|
||||
const userInfoList = article.user_info || [];
|
||||
const userHasAccess = userInfoList.some(ui => ui.username === user.username);
|
||||
|
||||
// También verificar compatibilidad con estructura antigua
|
||||
if (!userHasAccess && article.username !== user.username) {
|
||||
return res.status(403).json({ error: 'No tienes permiso para ver este artículo' });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(article);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import bcrypt from 'bcrypt';
|
||||
import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword } from '../services/mongodb.js';
|
||||
import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js';
|
||||
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||
import { combineFingerprint } from '../utils/fingerprint.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -14,7 +15,7 @@ router.post('/login', async (req, res) => {
|
||||
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
const { username, password, fingerprint: clientFingerprint, deviceInfo: clientDeviceInfo } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'username y password son requeridos' });
|
||||
@@ -41,8 +42,11 @@ router.post('/login', async (req, res) => {
|
||||
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
|
||||
}
|
||||
|
||||
// Crear sesión/token
|
||||
const token = await createSession(username);
|
||||
// Generar fingerprint del dispositivo
|
||||
const { fingerprint, deviceInfo } = combineFingerprint(clientFingerprint, clientDeviceInfo, req);
|
||||
|
||||
// Crear sesión/token con fingerprint
|
||||
const token = await createSession(username, fingerprint, deviceInfo);
|
||||
|
||||
// Obtener rol del usuario
|
||||
const userRole = user.role || 'user';
|
||||
|
||||
@@ -17,10 +17,14 @@ import configRouter from './routes/config.js';
|
||||
import telegramRouter from './routes/telegram.js';
|
||||
import pushRouter from './routes/push.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import adminRouter from './routes/admin.js';
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
// Configurar Express para confiar en proxies (necesario para obtener IP real)
|
||||
app.set('trust proxy', true);
|
||||
|
||||
// Middlewares globales
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
@@ -44,6 +48,7 @@ app.use('/api/config', configRouter);
|
||||
app.use('/api/telegram', telegramRouter);
|
||||
app.use('/api/push', pushRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
|
||||
// Inicializar servidor
|
||||
async function startServer() {
|
||||
|
||||
@@ -88,6 +88,8 @@ async function createIndexes() {
|
||||
// Índices para sesiones (con TTL)
|
||||
await db.collection('sessions').createIndex({ token: 1 }, { unique: true });
|
||||
await db.collection('sessions').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
await db.collection('sessions').createIndex({ username: 1, fingerprint: 1 });
|
||||
await db.collection('sessions').createIndex({ fingerprint: 1 });
|
||||
|
||||
// Índices para workers
|
||||
await db.collection('workers').createIndex({ username: 1 });
|
||||
@@ -259,6 +261,54 @@ export function getRateLimiter() {
|
||||
return rateLimiter;
|
||||
}
|
||||
|
||||
// Obtener información del rate limiter y bloqueos (para memoria)
|
||||
export async function getRateLimiterInfo() {
|
||||
if (!rateLimiter) {
|
||||
return {
|
||||
enabled: false,
|
||||
type: 'none',
|
||||
blocks: [],
|
||||
stats: {
|
||||
totalBlocks: 0,
|
||||
activeBlocks: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// RateLimiterMemory no expone directamente las claves bloqueadas
|
||||
// Necesitamos usar una aproximación diferente
|
||||
// Por ahora, retornamos información básica
|
||||
return {
|
||||
enabled: true,
|
||||
type: 'memory',
|
||||
blocks: [],
|
||||
stats: {
|
||||
totalBlocks: 0,
|
||||
activeBlocks: 0,
|
||||
},
|
||||
config: {
|
||||
points: RATE_LIMIT.POINTS,
|
||||
duration: RATE_LIMIT.DURATION,
|
||||
blockDuration: RATE_LIMIT.BLOCK_DURATION,
|
||||
},
|
||||
note: 'Rate limiter en memoria no expone información detallada de bloqueos',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo información del rate limiter:', error.message);
|
||||
return {
|
||||
enabled: true,
|
||||
type: 'memory',
|
||||
blocks: [],
|
||||
stats: {
|
||||
totalBlocks: 0,
|
||||
activeBlocks: 0,
|
||||
},
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
return config;
|
||||
}
|
||||
@@ -303,6 +353,9 @@ export async function getNotifiedArticles(filter = {}) {
|
||||
.sort({ createdAt: -1, modified_at: -1 })
|
||||
.toArray();
|
||||
|
||||
// Obtener el username del usuario actual para incluir su is_favorite
|
||||
const currentUsername = filter.currentUsername || filter.username;
|
||||
|
||||
// Filtrar y transformar artículos según el usuario solicitado
|
||||
return articles.map(article => {
|
||||
// Si hay filtro de username, solo devolver el user_info correspondiente
|
||||
@@ -350,7 +403,15 @@ export async function getNotifiedArticles(filter = {}) {
|
||||
if (firstUserInfo) {
|
||||
result.username = firstUserInfo.username;
|
||||
result.worker_name = firstUserInfo.worker_name;
|
||||
result.is_favorite = firstUserInfo.is_favorite || false;
|
||||
// Si hay un usuario actual, buscar su is_favorite específico, sino usar el del primer user_info
|
||||
if (currentUsername) {
|
||||
const currentUserInfo = (article.user_info || []).find(
|
||||
ui => ui.username === currentUsername
|
||||
);
|
||||
result.is_favorite = currentUserInfo ? (currentUserInfo.is_favorite || false) : false;
|
||||
} else {
|
||||
result.is_favorite = firstUserInfo.is_favorite || false;
|
||||
}
|
||||
result.notifiedAt =
|
||||
firstUserInfo.notified_at?.getTime?.() ||
|
||||
(typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) ||
|
||||
@@ -579,6 +640,9 @@ export async function searchArticles(searchQuery, filter = {}, searchMode = 'AND
|
||||
.sort({ createdAt: -1, modified_at: -1 })
|
||||
.toArray();
|
||||
|
||||
// Obtener el username del usuario actual para incluir su is_favorite
|
||||
const currentUsername = filter.currentUsername || filter.username;
|
||||
|
||||
// Transformar artículos según el usuario solicitado (similar a getNotifiedArticles)
|
||||
return articles.map(article => {
|
||||
let relevantUserInfo = null;
|
||||
@@ -619,7 +683,15 @@ export async function searchArticles(searchQuery, filter = {}, searchMode = 'AND
|
||||
if (firstUserInfo) {
|
||||
result.username = firstUserInfo.username;
|
||||
result.worker_name = firstUserInfo.worker_name;
|
||||
result.is_favorite = firstUserInfo.is_favorite || false;
|
||||
// Si hay un usuario actual, buscar su is_favorite específico, sino usar el del primer user_info
|
||||
if (currentUsername) {
|
||||
const currentUserInfo = (article.user_info || []).find(
|
||||
ui => ui.username === currentUsername
|
||||
);
|
||||
result.is_favorite = currentUserInfo ? (currentUserInfo.is_favorite || false) : false;
|
||||
} else {
|
||||
result.is_favorite = firstUserInfo.is_favorite || false;
|
||||
}
|
||||
result.notifiedAt =
|
||||
firstUserInfo.notified_at?.getTime?.() ||
|
||||
(typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) ||
|
||||
@@ -978,7 +1050,7 @@ export async function setTelegramConfig(username, telegramConfig) {
|
||||
}
|
||||
|
||||
// Funciones para sesiones
|
||||
export async function createSession(username) {
|
||||
export async function createSession(username, fingerprint = null, deviceInfo = null) {
|
||||
if (!db) {
|
||||
throw new Error('MongoDB no está disponible');
|
||||
}
|
||||
@@ -989,9 +1061,22 @@ export async function createSession(username) {
|
||||
|
||||
try {
|
||||
const sessionsCollection = db.collection('sessions');
|
||||
|
||||
// Si hay fingerprint, eliminar sesiones anteriores del mismo dispositivo/usuario
|
||||
if (fingerprint) {
|
||||
await sessionsCollection.deleteMany({
|
||||
username,
|
||||
fingerprint,
|
||||
expiresAt: { $gt: new Date() } // Solo eliminar sesiones activas
|
||||
});
|
||||
}
|
||||
|
||||
// Crear nueva sesión con fingerprint
|
||||
await sessionsCollection.insertOne({
|
||||
token,
|
||||
username,
|
||||
fingerprint: fingerprint || null,
|
||||
deviceInfo: deviceInfo || null,
|
||||
createdAt: new Date(),
|
||||
expiresAt,
|
||||
});
|
||||
@@ -1046,15 +1131,95 @@ export async function deleteUserSessions(username) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllSessions() {
|
||||
if (!db) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionsCollection = db.collection('sessions');
|
||||
const sessions = await sessionsCollection.find({}).toArray();
|
||||
return sessions.map(session => ({
|
||||
token: session.token,
|
||||
username: session.username,
|
||||
fingerprint: session.fingerprint || null,
|
||||
deviceInfo: session.deviceInfo || null,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
isExpired: session.expiresAt ? new Date(session.expiresAt) < new Date() : false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo todas las sesiones:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Funciones para artículos
|
||||
export async function getArticle(platform, id) {
|
||||
export async function getArticle(platform, id, currentUsername = null) {
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const articlesCollection = db.collection('articles');
|
||||
return await articlesCollection.findOne({ platform, id });
|
||||
const article = await articlesCollection.findOne({ platform, id });
|
||||
|
||||
if (!article) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transformar el artículo similar a getNotifiedArticles para incluir is_favorite
|
||||
const result = {
|
||||
...article,
|
||||
_id: article._id.toString(),
|
||||
expiresAt: article.expiresAt?.getTime() || null,
|
||||
};
|
||||
|
||||
const fallbackTime =
|
||||
article.createdAt?.getTime?.() ||
|
||||
(typeof article.createdAt === 'number' ? article.createdAt : null) ||
|
||||
article.modified_at?.getTime?.() ||
|
||||
null;
|
||||
|
||||
// Si hay un usuario actual, buscar su is_favorite específico
|
||||
if (currentUsername) {
|
||||
const currentUserInfo = (article.user_info || []).find(
|
||||
ui => ui.username === currentUsername
|
||||
);
|
||||
if (currentUserInfo) {
|
||||
result.is_favorite = currentUserInfo.is_favorite || false;
|
||||
result.notifiedAt =
|
||||
currentUserInfo.notified_at?.getTime?.() ||
|
||||
(typeof currentUserInfo.notified_at === 'number' ? currentUserInfo.notified_at : null) ||
|
||||
fallbackTime;
|
||||
} else {
|
||||
// Si no hay user_info para este usuario, usar false
|
||||
result.is_favorite = false;
|
||||
}
|
||||
} else {
|
||||
// Sin usuario específico, usar el primer user_info o datos generales
|
||||
const firstUserInfo = (article.user_info || [])[0];
|
||||
if (firstUserInfo) {
|
||||
result.username = firstUserInfo.username;
|
||||
result.worker_name = firstUserInfo.worker_name;
|
||||
result.is_favorite = firstUserInfo.is_favorite || false;
|
||||
result.notifiedAt =
|
||||
firstUserInfo.notified_at?.getTime?.() ||
|
||||
(typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) ||
|
||||
fallbackTime;
|
||||
} else {
|
||||
// Compatibilidad con estructura antigua
|
||||
result.username = article.username;
|
||||
result.worker_name = article.worker_name;
|
||||
result.is_favorite = article.is_favorite || false;
|
||||
result.notifiedAt =
|
||||
article.notifiedAt?.getTime?.() ||
|
||||
(typeof article.notifiedAt === 'number' ? article.notifiedAt : null) ||
|
||||
fallbackTime;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo artículo:', error.message);
|
||||
return null;
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
import redis from 'redis';
|
||||
import yaml from 'yaml';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { RateLimiterRedis } from 'rate-limiter-flexible';
|
||||
import { PATHS } from '../config/constants.js';
|
||||
import { RATE_LIMIT } from '../config/constants.js';
|
||||
import { readJSON } from '../utils/fileUtils.js';
|
||||
|
||||
let redisClient = null;
|
||||
let rateLimiter = null;
|
||||
let config = null;
|
||||
|
||||
// Inicializar Redis si está configurado
|
||||
export async function initRedis() {
|
||||
try {
|
||||
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
|
||||
const cacheConfig = config?.cache;
|
||||
|
||||
if (cacheConfig?.type === 'redis') {
|
||||
const redisConfig = cacheConfig.redis;
|
||||
// En Docker, usar el nombre del servicio si no se especifica host
|
||||
const redisHost = process.env.REDIS_HOST || redisConfig.host || 'localhost';
|
||||
redisClient = redis.createClient({
|
||||
socket: {
|
||||
host: redisHost,
|
||||
port: redisConfig.port || 6379,
|
||||
},
|
||||
password: redisConfig.password || undefined,
|
||||
database: redisConfig.db || 0,
|
||||
});
|
||||
|
||||
redisClient.on('error', (err) => console.error('Redis Client Error', err));
|
||||
await redisClient.connect();
|
||||
console.log('✅ Conectado a Redis');
|
||||
|
||||
// Inicializar rate limiter con Redis
|
||||
try {
|
||||
// Crear un cliente Redis adicional para el rate limiter
|
||||
const rateLimiterClient = redis.createClient({
|
||||
socket: {
|
||||
host: redisHost,
|
||||
port: redisConfig.port || 6379,
|
||||
},
|
||||
password: redisConfig.password || undefined,
|
||||
database: redisConfig.db || 0,
|
||||
});
|
||||
await rateLimiterClient.connect();
|
||||
|
||||
rateLimiter = new RateLimiterRedis({
|
||||
storeClient: rateLimiterClient,
|
||||
keyPrefix: 'rl:',
|
||||
points: RATE_LIMIT.POINTS,
|
||||
duration: RATE_LIMIT.DURATION,
|
||||
blockDuration: RATE_LIMIT.BLOCK_DURATION,
|
||||
});
|
||||
console.log('✅ Rate limiter inicializado con Redis');
|
||||
} catch (error) {
|
||||
console.error('Error inicializando rate limiter:', error.message);
|
||||
}
|
||||
|
||||
// Inicializar usuario admin por defecto si no existe
|
||||
await initDefaultAdmin();
|
||||
|
||||
// Migrar workers.json a Redis para admin si no existe
|
||||
await migrateWorkersFromFile();
|
||||
|
||||
} else {
|
||||
console.log('ℹ️ Redis no configurado, usando modo memoria');
|
||||
console.log('⚠️ Rate limiting y autenticación requieren Redis');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error inicializando Redis:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializar usuario admin por defecto
|
||||
async function initDefaultAdmin() {
|
||||
if (!redisClient) return;
|
||||
|
||||
try {
|
||||
const adminExists = await redisClient.exists('user:admin');
|
||||
if (!adminExists) {
|
||||
// Crear usuario admin por defecto con contraseña "admin"
|
||||
// En producción, esto debería cambiarse
|
||||
const defaultPassword = 'admin';
|
||||
const hashedPassword = await bcrypt.hash(defaultPassword, 10);
|
||||
await redisClient.hSet('user:admin', {
|
||||
username: 'admin',
|
||||
passwordHash: hashedPassword,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
console.log('✅ Usuario admin creado por defecto (usuario: admin, contraseña: admin)');
|
||||
console.log('⚠️ IMPORTANTE: Cambia la contraseña por defecto en producción');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error inicializando usuario admin:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
export function getRedisClient() {
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
export function getRateLimiter() {
|
||||
return rateLimiter;
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
return config;
|
||||
}
|
||||
|
||||
export function reloadConfig() {
|
||||
try {
|
||||
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error('Error recargando configuración:', error.message);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
// Funciones de utilidad para artículos
|
||||
export async function getNotifiedArticles() {
|
||||
if (!redisClient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = await redisClient.keys('notified:*');
|
||||
const articles = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const parts = key.split(':');
|
||||
if (parts.length >= 3) {
|
||||
const platform = parts[1];
|
||||
const id = parts.slice(2).join(':');
|
||||
const ttl = await redisClient.ttl(key);
|
||||
const value = await redisClient.get(key);
|
||||
|
||||
// Intentar parsear como JSON (nuevo formato con toda la info)
|
||||
let articleData = {};
|
||||
try {
|
||||
if (value && value !== '1') {
|
||||
articleData = JSON.parse(value);
|
||||
}
|
||||
} catch (e) {
|
||||
// Si no es JSON válido, usar valor por defecto
|
||||
}
|
||||
|
||||
articles.push({
|
||||
platform: articleData.platform || platform,
|
||||
id: articleData.id || id,
|
||||
title: articleData.title || null,
|
||||
description: articleData.description || null,
|
||||
price: articleData.price || null,
|
||||
currency: articleData.currency || null,
|
||||
location: articleData.location || null,
|
||||
allows_shipping: articleData.allows_shipping !== undefined ? articleData.allows_shipping : null,
|
||||
url: articleData.url || null,
|
||||
images: articleData.images || [],
|
||||
modified_at: articleData.modified_at || null,
|
||||
username: articleData.username || null,
|
||||
worker_name: articleData.worker_name || null,
|
||||
notifiedAt: Date.now() - (7 * 24 * 60 * 60 - ttl) * 1000,
|
||||
expiresAt: Date.now() + ttl * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return articles;
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo artículos de Redis:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFavorites() {
|
||||
if (!redisClient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = await redisClient.keys('notified:*');
|
||||
const favorites = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const value = await redisClient.get(key);
|
||||
if (value) {
|
||||
try {
|
||||
const articleData = JSON.parse(value);
|
||||
if (articleData.is_favorite === true) {
|
||||
favorites.push(articleData);
|
||||
}
|
||||
} catch (e) {
|
||||
// Si no es JSON válido, ignorar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return favorites;
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo favoritos de Redis:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializar claves conocidas para evitar notificar artículos existentes
|
||||
export async function initNotifiedArticleKeys() {
|
||||
if (!redisClient) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
const initialKeys = await redisClient.keys('notified:*');
|
||||
const keysSet = new Set(initialKeys);
|
||||
console.log(`📋 ${keysSet.size} artículos ya notificados detectados`);
|
||||
return keysSet;
|
||||
} catch (error) {
|
||||
console.error('Error inicializando claves de artículos:', error.message);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
// Funciones para manejar workers por usuario
|
||||
export async function getWorkers(username) {
|
||||
if (!redisClient) {
|
||||
throw new Error('Redis no está disponible');
|
||||
}
|
||||
|
||||
try {
|
||||
const workersKey = `workers:${username}`;
|
||||
const workersData = await redisClient.get(workersKey);
|
||||
|
||||
if (!workersData) {
|
||||
// Retornar estructura vacía por defecto
|
||||
return {
|
||||
general: {
|
||||
title_exclude: [],
|
||||
description_exclude: []
|
||||
},
|
||||
items: [],
|
||||
disabled: []
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.parse(workersData);
|
||||
} catch (error) {
|
||||
console.error(`Error obteniendo workers para ${username}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setWorkers(username, workers) {
|
||||
if (!redisClient) {
|
||||
throw new Error('Redis no está disponible');
|
||||
}
|
||||
|
||||
try {
|
||||
const workersKey = `workers:${username}`;
|
||||
const workersData = JSON.stringify(workers);
|
||||
await redisClient.set(workersKey, workersData);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error guardando workers para ${username}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Migrar workers.json a Redis para el usuario admin si no existe
|
||||
async function migrateWorkersFromFile() {
|
||||
if (!redisClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const adminWorkersKey = 'workers:admin';
|
||||
const adminWorkersExists = await redisClient.exists(adminWorkersKey);
|
||||
|
||||
// Si ya existen workers para admin en Redis, no migrar
|
||||
if (adminWorkersExists) {
|
||||
console.log('ℹ️ Workers de admin ya existen en Redis, omitiendo migración');
|
||||
return;
|
||||
}
|
||||
|
||||
// Intentar leer workers.json
|
||||
if (!existsSync(PATHS.WORKERS)) {
|
||||
console.log('ℹ️ workers.json no existe, creando estructura vacía para admin');
|
||||
// Crear estructura vacía por defecto
|
||||
const defaultWorkers = {
|
||||
general: {
|
||||
title_exclude: [],
|
||||
description_exclude: []
|
||||
},
|
||||
items: [],
|
||||
disabled: []
|
||||
};
|
||||
await setWorkers('admin', defaultWorkers);
|
||||
return;
|
||||
}
|
||||
|
||||
// Leer workers.json y migrar a Redis
|
||||
const workersData = readJSON(PATHS.WORKERS, {
|
||||
general: {
|
||||
title_exclude: [],
|
||||
description_exclude: []
|
||||
},
|
||||
items: [],
|
||||
disabled: []
|
||||
});
|
||||
|
||||
// Guardar en Redis para admin
|
||||
await setWorkers('admin', workersData);
|
||||
console.log(`✅ Workers migrados desde workers.json al usuario admin (${workersData.items?.length || 0} items)`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error migrando workers.json a Redis:', error.message);
|
||||
// No lanzar error, solo registrar
|
||||
}
|
||||
}
|
||||
|
||||
165
web/backend/utils/fingerprint.js
Normal file
165
web/backend/utils/fingerprint.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Genera un fingerprint del dispositivo basado en headers HTTP
|
||||
* @param {Object} req - Request object de Express
|
||||
* @returns {Object} Objeto con fingerprint hash y metadata del dispositivo
|
||||
*/
|
||||
export function generateDeviceFingerprint(req) {
|
||||
// Extraer información del dispositivo desde headers
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
const acceptLanguage = req.headers['accept-language'] || '';
|
||||
const acceptEncoding = req.headers['accept-encoding'] || '';
|
||||
const accept = req.headers['accept'] || '';
|
||||
const connection = req.headers['connection'] || '';
|
||||
const upgradeInsecureRequests = req.headers['upgrade-insecure-requests'] || '';
|
||||
|
||||
// IP del cliente (considerando proxies)
|
||||
const ip = req.ip ||
|
||||
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection?.remoteAddress ||
|
||||
'unknown';
|
||||
|
||||
// Crear string combinado para el hash
|
||||
const fingerprintString = [
|
||||
userAgent,
|
||||
acceptLanguage,
|
||||
acceptEncoding,
|
||||
accept,
|
||||
connection,
|
||||
upgradeInsecureRequests,
|
||||
ip
|
||||
].join('|');
|
||||
|
||||
// Generar hash SHA-256
|
||||
const fingerprintHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(fingerprintString)
|
||||
.digest('hex');
|
||||
|
||||
// Extraer información legible del User-Agent
|
||||
const deviceInfo = parseUserAgent(userAgent);
|
||||
|
||||
return {
|
||||
fingerprint: fingerprintHash,
|
||||
deviceInfo: {
|
||||
...deviceInfo,
|
||||
ip: ip,
|
||||
userAgent: userAgent.substring(0, 200), // Limitar longitud
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea el User-Agent para extraer información legible
|
||||
* @param {string} userAgent - User-Agent string
|
||||
* @returns {Object} Información del dispositivo
|
||||
*/
|
||||
function parseUserAgent(userAgent) {
|
||||
if (!userAgent) {
|
||||
return {
|
||||
browser: 'Unknown',
|
||||
browserVersion: '',
|
||||
os: 'Unknown',
|
||||
osVersion: '',
|
||||
device: 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
const ua = userAgent.toLowerCase();
|
||||
|
||||
// Detectar navegador
|
||||
let browser = 'Unknown';
|
||||
let browserVersion = '';
|
||||
|
||||
if (ua.includes('chrome') && !ua.includes('edg') && !ua.includes('opr')) {
|
||||
browser = 'Chrome';
|
||||
const match = ua.match(/chrome\/([\d.]+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
} else if (ua.includes('firefox')) {
|
||||
browser = 'Firefox';
|
||||
const match = ua.match(/firefox\/([\d.]+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
} else if (ua.includes('safari') && !ua.includes('chrome')) {
|
||||
browser = 'Safari';
|
||||
const match = ua.match(/version\/([\d.]+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
} else if (ua.includes('edg')) {
|
||||
browser = 'Edge';
|
||||
const match = ua.match(/edg\/([\d.]+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
} else if (ua.includes('opr')) {
|
||||
browser = 'Opera';
|
||||
const match = ua.match(/opr\/([\d.]+)/);
|
||||
browserVersion = match ? match[1] : '';
|
||||
}
|
||||
|
||||
// Detectar sistema operativo
|
||||
let os = 'Unknown';
|
||||
let osVersion = '';
|
||||
|
||||
if (ua.includes('windows')) {
|
||||
os = 'Windows';
|
||||
if (ua.includes('windows nt 10')) osVersion = '10/11';
|
||||
else if (ua.includes('windows nt 6.3')) osVersion = '8.1';
|
||||
else if (ua.includes('windows nt 6.2')) osVersion = '8';
|
||||
else if (ua.includes('windows nt 6.1')) osVersion = '7';
|
||||
} else if (ua.includes('mac os x') || ua.includes('macintosh')) {
|
||||
os = 'macOS';
|
||||
const match = ua.match(/mac os x ([\d_]+)/);
|
||||
osVersion = match ? match[1].replace(/_/g, '.') : '';
|
||||
} else if (ua.includes('linux')) {
|
||||
os = 'Linux';
|
||||
} else if (ua.includes('android')) {
|
||||
os = 'Android';
|
||||
const match = ua.match(/android ([\d.]+)/);
|
||||
osVersion = match ? match[1] : '';
|
||||
} else if (ua.includes('ios') || ua.includes('iphone') || ua.includes('ipad')) {
|
||||
os = 'iOS';
|
||||
const match = ua.match(/os ([\d_]+)/);
|
||||
osVersion = match ? match[1].replace(/_/g, '.') : '';
|
||||
}
|
||||
|
||||
// Detectar tipo de dispositivo
|
||||
let device = 'Desktop';
|
||||
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
||||
device = 'Mobile';
|
||||
} else if (ua.includes('tablet') || ua.includes('ipad')) {
|
||||
device = 'Tablet';
|
||||
}
|
||||
|
||||
return {
|
||||
browser,
|
||||
browserVersion,
|
||||
os,
|
||||
osVersion,
|
||||
device,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un fingerprint desde el frontend (cuando se envía desde el cliente)
|
||||
* @param {string} fingerprintHash - Hash del fingerprint generado en el cliente
|
||||
* @param {Object} deviceInfo - Información del dispositivo del cliente
|
||||
* @param {Object} req - Request object de Express
|
||||
* @returns {Object} Objeto con fingerprint y metadata combinada
|
||||
*/
|
||||
export function combineFingerprint(fingerprintHash, deviceInfo, req) {
|
||||
const serverFingerprint = generateDeviceFingerprint(req);
|
||||
|
||||
// Si el cliente envió un fingerprint, combinarlo con el del servidor
|
||||
// Esto permite usar librerías avanzadas del cliente pero validar con servidor
|
||||
const combinedFingerprint = fingerprintHash
|
||||
? crypto.createHash('sha256').update(fingerprintHash + serverFingerprint.fingerprint).digest('hex')
|
||||
: serverFingerprint.fingerprint;
|
||||
|
||||
return {
|
||||
fingerprint: combinedFingerprint,
|
||||
deviceInfo: {
|
||||
...serverFingerprint.deviceInfo,
|
||||
...deviceInfo, // El del cliente tiene prioridad si existe
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user