feat: implement user authentication and login modal, refactor backend

This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 00:39:28 +01:00
parent 9a61f16959
commit e99424c9ba
29 changed files with 3061 additions and 855 deletions

View File

@@ -0,0 +1,81 @@
import express from 'express';
import { getNotifiedArticles } from '../services/redis.js';
const router = express.Router();
// Obtener artículos notificados
router.get('/', async (req, res) => {
try {
const articles = await getNotifiedArticles();
const limit = parseInt(req.query.limit) || 100;
const offset = parseInt(req.query.offset) || 0;
const sorted = articles.sort((a, b) => b.notifiedAt - a.notifiedAt);
const paginated = sorted.slice(offset, offset + limit);
res.json({
articles: paginated,
total: articles.length,
limit,
offset,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Buscar artículos en Redis
router.get('/search', async (req, res) => {
try {
const query = req.query.q || '';
if (!query.trim()) {
return res.json({ articles: [], total: 0 });
}
const searchTerm = query.toLowerCase().trim();
const allArticles = await getNotifiedArticles();
// Filtrar artículos que coincidan con la búsqueda
const filtered = allArticles.filter(article => {
// Buscar en título
const title = (article.title || '').toLowerCase();
if (title.includes(searchTerm)) return true;
// Buscar en descripción
const description = (article.description || '').toLowerCase();
if (description.includes(searchTerm)) return true;
// Buscar en localidad
const location = (article.location || '').toLowerCase();
if (location.includes(searchTerm)) return true;
// Buscar en precio (como número o texto)
const price = String(article.price || '').toLowerCase();
if (price.includes(searchTerm)) return true;
// Buscar en plataforma
const platform = (article.platform || '').toLowerCase();
if (platform.includes(searchTerm)) return true;
// Buscar en ID
const id = String(article.id || '').toLowerCase();
if (id.includes(searchTerm)) return true;
return false;
});
// Ordenar por fecha de notificación (más recientes primero)
const sorted = filtered.sort((a, b) => b.notifiedAt - a.notifiedAt);
res.json({
articles: sorted,
total: sorted.length,
query: query,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,29 @@
import express from 'express';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { getConfig, reloadConfig } from '../services/redis.js';
import { readFileSync } from 'fs';
import yaml from 'yaml';
import { PATHS } from '../config/constants.js';
const router = express.Router();
// Obtener configuración
router.get('/', basicAuthMiddleware, (req, res) => {
try {
let config = getConfig();
if (!config) {
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
}
// No enviar token por seguridad
const safeConfig = { ...config };
if (safeConfig.telegram_token) {
safeConfig.telegram_token = '***';
}
res.json(safeConfig);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,99 @@
import express from 'express';
import { getFavorites, getRedisClient } from '../services/redis.js';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { broadcast } from '../services/websocket.js';
const router = express.Router();
// Obtener favoritos
router.get('/', async (req, res) => {
try {
const favorites = await getFavorites();
res.json(favorites);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Añadir favorito (requiere autenticación)
router.post('/', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { platform, id } = req.body;
if (!platform || !id) {
return res.status(400).json({ error: 'platform e id son requeridos' });
}
const key = `notified:${platform}:${id}`;
const value = await redisClient.get(key);
if (!value) {
return res.status(404).json({ error: 'Artículo no encontrado' });
}
try {
const articleData = JSON.parse(value);
articleData.is_favorite = true;
// Mantener el TTL existente
const ttl = await redisClient.ttl(key);
if (ttl > 0) {
await redisClient.setex(key, ttl, JSON.stringify(articleData));
} else {
await redisClient.set(key, JSON.stringify(articleData));
}
const favorites = await getFavorites();
broadcast({ type: 'favorites_updated', data: favorites });
res.json({ success: true, favorites });
} catch (e) {
res.status(500).json({ error: 'Error procesando artículo' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Eliminar favorito (requiere autenticación)
router.delete('/:platform/:id', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { platform, id } = req.params;
const key = `notified:${platform}:${id}`;
const value = await redisClient.get(key);
if (!value) {
return res.status(404).json({ error: 'Artículo no encontrado' });
}
try {
const articleData = JSON.parse(value);
articleData.is_favorite = false;
// Mantener el TTL existente
const ttl = await redisClient.ttl(key);
if (ttl > 0) {
await redisClient.setex(key, ttl, JSON.stringify(articleData));
} else {
await redisClient.set(key, JSON.stringify(articleData));
}
const favorites = await getFavorites();
broadcast({ type: 'favorites_updated', data: favorites });
res.json({ success: true, favorites });
} catch (e) {
res.status(500).json({ error: 'Error procesando artículo' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,81 @@
import express from 'express';
import { readJSON } from '../utils/fileUtils.js';
import { PATHS } from '../config/constants.js';
import { getFavorites, getNotifiedArticles, getRedisClient } from '../services/redis.js';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { broadcast } from '../services/websocket.js';
const router = express.Router();
// Obtener estadísticas
router.get('/stats', async (req, res) => {
try {
const workers = readJSON(PATHS.WORKERS, { items: [] });
const favorites = await getFavorites();
const notifiedArticles = await getNotifiedArticles();
const stats = {
totalWorkers: workers.items?.length || 0,
activeWorkers: (workers.items || []).filter(w => !workers.disabled?.includes(w.name)).length,
totalFavorites: favorites.length,
totalNotified: notifiedArticles.length,
platforms: {
wallapop: notifiedArticles.filter(a => a.platform === 'wallapop').length,
vinted: notifiedArticles.filter(a => a.platform === 'vinted').length,
},
};
res.json(stats);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Limpiar toda la caché de Redis (requiere autenticación)
router.delete('/cache', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
// Obtener todas las claves que empiezan con 'notified:'
const keys = await redisClient.keys('notified:*');
if (!keys || keys.length === 0) {
return res.json({
success: true,
message: 'Cache ya está vacío',
count: 0
});
}
// Eliminar todas las claves
const count = keys.length;
for (const key of keys) {
await redisClient.del(key);
}
// Notificar a los clientes WebSocket
broadcast({
type: 'cache_cleared',
data: { count, timestamp: Date.now() }
});
// También actualizar favoritos (debería estar vacío ahora)
const favorites = await getFavorites();
broadcast({ type: 'favorites_updated', data: favorites });
res.json({
success: true,
message: `Cache limpiado: ${count} artículos eliminados`,
count
});
} catch (error) {
console.error('Error limpiando cache de Redis:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,22 @@
import express from 'express';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { getLogPath, readLogs } from '../utils/fileUtils.js';
const router = express.Router();
// Obtener logs (últimas líneas o nuevas líneas desde un número de línea)
router.get('/', basicAuthMiddleware, (req, res) => {
try {
const logPath = getLogPath();
const sinceLine = parseInt(req.query.since) || 0;
const limit = parseInt(req.query.limit) || 500;
const result = readLogs(logPath, { sinceLine, limit });
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,73 @@
import express from 'express';
import { getPublicKey, getPushSubscriptions, savePushSubscriptions } from '../services/webPush.js';
const router = express.Router();
// Obtener clave pública VAPID
router.get('/public-key', (req, res) => {
try {
const publicKey = getPublicKey();
res.json({ publicKey });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Suscribirse a notificaciones push
router.post('/subscribe', async (req, res) => {
try {
const subscription = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Suscripción inválida' });
}
const subscriptions = getPushSubscriptions();
// Verificar si ya existe esta suscripción
const existingIndex = subscriptions.findIndex(
sub => sub.endpoint === subscription.endpoint
);
if (existingIndex >= 0) {
subscriptions[existingIndex] = subscription;
} else {
subscriptions.push(subscription);
}
savePushSubscriptions(subscriptions);
console.log(`✅ Nueva suscripción push guardada. Total: ${subscriptions.length}`);
res.json({ success: true, totalSubscriptions: subscriptions.length });
} catch (error) {
console.error('Error guardando suscripción push:', error);
res.status(500).json({ error: error.message });
}
});
// Cancelar suscripción push
router.post('/unsubscribe', async (req, res) => {
try {
const subscription = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Suscripción inválida' });
}
const subscriptions = getPushSubscriptions();
const filtered = subscriptions.filter(
sub => sub.endpoint !== subscription.endpoint
);
savePushSubscriptions(filtered);
console.log(`✅ Suscripción push cancelada. Total: ${filtered.length}`);
res.json({ success: true, totalSubscriptions: filtered.length });
} catch (error) {
console.error('Error cancelando suscripción push:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,70 @@
import express from 'express';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { getConfig, reloadConfig } from '../services/redis.js';
import { readFileSync } from 'fs';
import yaml from 'yaml';
import { PATHS } from '../config/constants.js';
const router = express.Router();
// Obtener threads/topics de Telegram
router.get('/threads', basicAuthMiddleware, async (req, res) => {
try {
let config = getConfig();
if (!config) {
config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8'));
}
const token = config?.telegram_token;
const channel = config?.telegram_channel;
if (!token || !channel) {
return res.status(400).json({ error: 'Token o canal de Telegram no configurados' });
}
// Convertir el canal a chat_id si es necesario
let chatId = channel;
if (channel.startsWith('@')) {
// Para canales con @, necesitamos obtener el chat_id primero
const getChatUrl = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(channel)}`;
const chatResponse = await fetch(getChatUrl);
const chatData = await chatResponse.json();
if (!chatData.ok) {
return res.status(400).json({ error: `Error obteniendo chat: ${chatData.description || 'Chat no encontrado'}` });
}
chatId = chatData.result.id;
}
// Intentar obtener forum topics
const forumTopicsUrl = `https://api.telegram.org/bot${token}/getForumTopics?chat_id=${chatId}&limit=100`;
const topicsResponse = await fetch(forumTopicsUrl);
const topicsData = await topicsResponse.json();
if (topicsData.ok && topicsData.result?.topics) {
const threads = topicsData.result.topics.map(topic => ({
id: topic.message_thread_id,
name: topic.name || `Thread ${topic.message_thread_id}`,
icon_color: topic.icon_color,
icon_custom_emoji_id: topic.icon_custom_emoji_id,
}));
return res.json({ threads, success: true });
} else {
// Si no hay forum topics, devolver un mensaje informativo
return res.json({
threads: [],
success: false,
message: 'El chat no tiene forum topics habilitados o no se pudieron obtener. Puedes obtener el Thread ID manualmente copiando el enlace del tema.',
info: 'Para obtener el Thread ID manualmente: 1. Haz clic derecho en el tema/hilo en Telegram 2. Selecciona "Copiar enlace del tema" 3. El número al final de la URL es el Thread ID (ej: t.me/c/1234567890/8 → Thread ID = 8)'
});
}
} catch (error) {
console.error('Error obteniendo threads de Telegram:', error.message);
res.status(500).json({ error: error.message });
}
});
export default router;

177
web/backend/routes/users.js Normal file
View File

@@ -0,0 +1,177 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { getRedisClient } from '../services/redis.js';
import { basicAuthMiddleware } from '../middlewares/auth.js';
const router = express.Router();
// Cambiar contraseña de usuario
router.post('/change-password', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { currentPassword, newPassword } = req.body;
const username = req.user.username;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'currentPassword y newPassword son requeridos' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: 'La nueva contraseña debe tener al menos 6 caracteres' });
}
const userKey = `user:${username}`;
const userData = await redisClient.hGetAll(userKey);
if (!userData || !userData.passwordHash) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
// Verificar contraseña actual
const match = await bcrypt.compare(currentPassword, userData.passwordHash);
if (!match) {
return res.status(401).json({ error: 'Contraseña actual incorrecta' });
}
// Hashear nueva contraseña y actualizar
const newPasswordHash = await bcrypt.hash(newPassword, 10);
await redisClient.hSet(userKey, {
...userData,
passwordHash: newPasswordHash,
updatedAt: new Date().toISOString(),
});
console.log(`✅ Contraseña actualizada para usuario: ${username}`);
res.json({ success: true, message: 'Contraseña actualizada correctamente' });
} catch (error) {
console.error('Error cambiando contraseña:', error);
res.status(500).json({ error: error.message });
}
});
// Obtener lista de usuarios (requiere autenticación admin)
router.get('/', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
// Obtener todas las claves de usuarios
const userKeys = await redisClient.keys('user:*');
const users = [];
for (const key of userKeys) {
const username = key.replace('user:', '');
const userData = await redisClient.hGetAll(key);
if (userData && userData.username) {
users.push({
username: userData.username,
createdAt: userData.createdAt || null,
updatedAt: userData.updatedAt || null,
createdBy: userData.createdBy || null,
});
}
}
// Ordenar por fecha de creación (más recientes primero)
users.sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA;
});
res.json({ users, total: users.length });
} catch (error) {
console.error('Error obteniendo usuarios:', error);
res.status(500).json({ error: error.message });
}
});
// Crear nuevo usuario (requiere autenticación admin)
router.post('/', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'username y password son requeridos' });
}
if (username.length < 3) {
return res.status(400).json({ error: 'El nombre de usuario debe tener al menos 3 caracteres' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' });
}
const userKey = `user:${username}`;
const userExists = await redisClient.exists(userKey);
if (userExists) {
return res.status(409).json({ error: 'El usuario ya existe' });
}
// Hashear contraseña y crear usuario
const passwordHash = await bcrypt.hash(password, 10);
await redisClient.hSet(userKey, {
username,
passwordHash,
createdAt: new Date().toISOString(),
createdBy: req.user.username,
});
console.log(`✅ Usuario creado: ${username} por ${req.user.username}`);
res.json({ success: true, message: 'Usuario creado correctamente', username });
} catch (error) {
console.error('Error creando usuario:', error);
res.status(500).json({ error: error.message });
}
});
// Eliminar usuario (requiere autenticación admin)
router.delete('/:username', basicAuthMiddleware, async (req, res) => {
try {
const redisClient = getRedisClient();
if (!redisClient) {
return res.status(500).json({ error: 'Redis no está disponible' });
}
const { username } = req.params;
const currentUser = req.user.username;
// No permitir eliminar el propio usuario
if (username === currentUser) {
return res.status(400).json({ error: 'No puedes eliminar tu propio usuario' });
}
const userKey = `user:${username}`;
const userExists = await redisClient.exists(userKey);
if (!userExists) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
// Eliminar usuario
await redisClient.del(userKey);
console.log(`✅ Usuario eliminado: ${username} por ${currentUser}`);
res.json({ success: true, message: `Usuario ${username} eliminado correctamente` });
} catch (error) {
console.error('Error eliminando usuario:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,35 @@
import express from 'express';
import { readJSON, writeJSON } from '../utils/fileUtils.js';
import { PATHS } from '../config/constants.js';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { broadcast } from '../services/websocket.js';
const router = express.Router();
// Obtener workers (requiere autenticación - solo administradores)
router.get('/', basicAuthMiddleware, (req, res) => {
try {
const workers = readJSON(PATHS.WORKERS, { items: [], general: {}, disabled: [] });
res.json(workers);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Actualizar workers (requiere autenticación)
router.put('/', basicAuthMiddleware, (req, res) => {
try {
const workers = req.body;
if (writeJSON(PATHS.WORKERS, workers)) {
broadcast({ type: 'workers_updated', data: workers });
res.json({ success: true });
} else {
res.status(500).json({ error: 'Error guardando workers' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;