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;
|
export const basicAuthMiddleware = authMiddleware;
|
||||||
|
|
||||||
// Función para crear sesión
|
// 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');
|
const { createSession: createSessionInDB } = await import('../services/mongodb.js');
|
||||||
return await createSessionInDB(username);
|
return await createSessionInDB(username, fingerprint, deviceInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Función para invalidar sesión
|
// 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 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';
|
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||||
|
|
||||||
const router = express.Router();
|
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.worker_name) filter.worker_name = req.query.worker_name;
|
||||||
if (req.query.platform) filter.platform = req.query.platform;
|
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 articles = await getNotifiedArticles(filter);
|
||||||
|
|
||||||
const limit = parseInt(req.query.limit) || 100;
|
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.worker_name) filter.worker_name = req.query.worker_name;
|
||||||
if (req.query.platform) filter.platform = req.query.platform;
|
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
|
// Obtener modo de búsqueda (AND u OR), por defecto AND
|
||||||
const searchMode = (req.query.mode || 'AND').toUpperCase();
|
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;
|
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 { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword } from '../services/mongodb.js';
|
||||||
import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js';
|
import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js';
|
||||||
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||||
|
import { combineFingerprint } from '../utils/fingerprint.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ router.post('/login', async (req, res) => {
|
|||||||
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
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) {
|
if (!username || !password) {
|
||||||
return res.status(400).json({ error: 'username y password son requeridos' });
|
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' });
|
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear sesión/token
|
// Generar fingerprint del dispositivo
|
||||||
const token = await createSession(username);
|
const { fingerprint, deviceInfo } = combineFingerprint(clientFingerprint, clientDeviceInfo, req);
|
||||||
|
|
||||||
|
// Crear sesión/token con fingerprint
|
||||||
|
const token = await createSession(username, fingerprint, deviceInfo);
|
||||||
|
|
||||||
// Obtener rol del usuario
|
// Obtener rol del usuario
|
||||||
const userRole = user.role || 'user';
|
const userRole = user.role || 'user';
|
||||||
|
|||||||
@@ -17,10 +17,14 @@ import configRouter from './routes/config.js';
|
|||||||
import telegramRouter from './routes/telegram.js';
|
import telegramRouter from './routes/telegram.js';
|
||||||
import pushRouter from './routes/push.js';
|
import pushRouter from './routes/push.js';
|
||||||
import usersRouter from './routes/users.js';
|
import usersRouter from './routes/users.js';
|
||||||
|
import adminRouter from './routes/admin.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|
||||||
|
// Configurar Express para confiar en proxies (necesario para obtener IP real)
|
||||||
|
app.set('trust proxy', true);
|
||||||
|
|
||||||
// Middlewares globales
|
// Middlewares globales
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -44,6 +48,7 @@ app.use('/api/config', configRouter);
|
|||||||
app.use('/api/telegram', telegramRouter);
|
app.use('/api/telegram', telegramRouter);
|
||||||
app.use('/api/push', pushRouter);
|
app.use('/api/push', pushRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
|
app.use('/api/admin', adminRouter);
|
||||||
|
|
||||||
// Inicializar servidor
|
// Inicializar servidor
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ async function createIndexes() {
|
|||||||
// Índices para sesiones (con TTL)
|
// Índices para sesiones (con TTL)
|
||||||
await db.collection('sessions').createIndex({ token: 1 }, { unique: true });
|
await db.collection('sessions').createIndex({ token: 1 }, { unique: true });
|
||||||
await db.collection('sessions').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
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
|
// Índices para workers
|
||||||
await db.collection('workers').createIndex({ username: 1 });
|
await db.collection('workers').createIndex({ username: 1 });
|
||||||
@@ -259,6 +261,54 @@ export function getRateLimiter() {
|
|||||||
return rateLimiter;
|
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() {
|
export function getConfig() {
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
@@ -303,6 +353,9 @@ export async function getNotifiedArticles(filter = {}) {
|
|||||||
.sort({ createdAt: -1, modified_at: -1 })
|
.sort({ createdAt: -1, modified_at: -1 })
|
||||||
.toArray();
|
.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
|
// Filtrar y transformar artículos según el usuario solicitado
|
||||||
return articles.map(article => {
|
return articles.map(article => {
|
||||||
// Si hay filtro de username, solo devolver el user_info correspondiente
|
// Si hay filtro de username, solo devolver el user_info correspondiente
|
||||||
@@ -350,7 +403,15 @@ export async function getNotifiedArticles(filter = {}) {
|
|||||||
if (firstUserInfo) {
|
if (firstUserInfo) {
|
||||||
result.username = firstUserInfo.username;
|
result.username = firstUserInfo.username;
|
||||||
result.worker_name = firstUserInfo.worker_name;
|
result.worker_name = firstUserInfo.worker_name;
|
||||||
|
// 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.is_favorite = firstUserInfo.is_favorite || false;
|
||||||
|
}
|
||||||
result.notifiedAt =
|
result.notifiedAt =
|
||||||
firstUserInfo.notified_at?.getTime?.() ||
|
firstUserInfo.notified_at?.getTime?.() ||
|
||||||
(typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) ||
|
(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 })
|
.sort({ createdAt: -1, modified_at: -1 })
|
||||||
.toArray();
|
.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)
|
// Transformar artículos según el usuario solicitado (similar a getNotifiedArticles)
|
||||||
return articles.map(article => {
|
return articles.map(article => {
|
||||||
let relevantUserInfo = null;
|
let relevantUserInfo = null;
|
||||||
@@ -619,7 +683,15 @@ export async function searchArticles(searchQuery, filter = {}, searchMode = 'AND
|
|||||||
if (firstUserInfo) {
|
if (firstUserInfo) {
|
||||||
result.username = firstUserInfo.username;
|
result.username = firstUserInfo.username;
|
||||||
result.worker_name = firstUserInfo.worker_name;
|
result.worker_name = firstUserInfo.worker_name;
|
||||||
|
// 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.is_favorite = firstUserInfo.is_favorite || false;
|
||||||
|
}
|
||||||
result.notifiedAt =
|
result.notifiedAt =
|
||||||
firstUserInfo.notified_at?.getTime?.() ||
|
firstUserInfo.notified_at?.getTime?.() ||
|
||||||
(typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) ||
|
(typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) ||
|
||||||
@@ -978,7 +1050,7 @@ export async function setTelegramConfig(username, telegramConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Funciones para sesiones
|
// Funciones para sesiones
|
||||||
export async function createSession(username) {
|
export async function createSession(username, fingerprint = null, deviceInfo = null) {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
throw new Error('MongoDB no está disponible');
|
throw new Error('MongoDB no está disponible');
|
||||||
}
|
}
|
||||||
@@ -989,9 +1061,22 @@ export async function createSession(username) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionsCollection = db.collection('sessions');
|
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({
|
await sessionsCollection.insertOne({
|
||||||
token,
|
token,
|
||||||
username,
|
username,
|
||||||
|
fingerprint: fingerprint || null,
|
||||||
|
deviceInfo: deviceInfo || null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
expiresAt,
|
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
|
// Funciones para artículos
|
||||||
export async function getArticle(platform, id) {
|
export async function getArticle(platform, id, currentUsername = null) {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const articlesCollection = db.collection('articles');
|
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) {
|
} catch (error) {
|
||||||
console.error('Error obteniendo artículo:', error.message);
|
console.error('Error obteniendo artículo:', error.message);
|
||||||
return null;
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
7
web/frontend/package-lock.json
generated
7
web/frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "wallabicher-frontend",
|
"name": "wallabicher-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||||
"@heroicons/vue": "^2.1.1",
|
"@heroicons/vue": "^2.1.1",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
@@ -473,6 +474,12 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fingerprintjs/fingerprintjs": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-KbaeE/rk2WL8MfpRP6jTI4lSr42SJPjvkyrjP3QU6uUDkOMWWYC2Ts1sNSYcegHC8avzOoYTHBj+2fTqvZWQBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@heroicons/vue": {
|
"node_modules/@heroicons/vue": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||||
|
|||||||
@@ -8,19 +8,19 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.3.4",
|
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||||
"vue-router": "^4.2.5",
|
|
||||||
"axios": "^1.6.0",
|
|
||||||
"@heroicons/vue": "^2.1.1",
|
"@heroicons/vue": "^2.1.1",
|
||||||
|
"axios": "^1.6.0",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"vue-chartjs": "^5.2.0"
|
"vue": "^3.3.4",
|
||||||
|
"vue-chartjs": "^5.2.0",
|
||||||
|
"vue-router": "^4.2.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.4.0",
|
"@vitejs/plugin-vue": "^4.4.0",
|
||||||
"vite": "^5.0.0",
|
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"tailwindcss": "^3.3.5"
|
"tailwindcss": "^3.3.5",
|
||||||
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ import {
|
|||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
DocumentMagnifyingGlassIcon,
|
DocumentMagnifyingGlassIcon,
|
||||||
|
ShieldExclamationIcon,
|
||||||
|
ClockIcon,
|
||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
SunIcon,
|
SunIcon,
|
||||||
@@ -187,6 +189,8 @@ const allNavItems = [
|
|||||||
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon, adminOnly: false },
|
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon, adminOnly: false },
|
||||||
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon, adminOnly: false },
|
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon, adminOnly: false },
|
||||||
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon, adminOnly: true },
|
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon, adminOnly: true },
|
||||||
|
{ path: '/rate-limiter', name: 'Rate Limiter', icon: ShieldExclamationIcon, adminOnly: true },
|
||||||
|
{ path: '/sessions', name: 'Sesiones', icon: ClockIcon, adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -9,7 +9,11 @@
|
|||||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||||
<!-- Imagen del artículo -->
|
<!-- Imagen del artículo -->
|
||||||
<div class="flex-shrink-0 self-center sm:self-start">
|
<div class="flex-shrink-0 self-center sm:self-start">
|
||||||
<div v-if="article.images && article.images.length > 0" class="w-24 h-24 sm:w-32 sm:h-32 relative">
|
<div
|
||||||
|
v-if="article.images && article.images.length > 0"
|
||||||
|
class="w-24 h-24 sm:w-32 sm:h-32 relative cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
@click="goToDetail"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
:src="article.images[0]"
|
:src="article.images[0]"
|
||||||
:alt="article.title || 'Sin título'"
|
:alt="article.title || 'Sin título'"
|
||||||
@@ -17,7 +21,11 @@
|
|||||||
@error="($event) => handleImageError($event)"
|
@error="($event) => handleImageError($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-24 h-24 sm:w-32 sm:h-32 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-24 h-24 sm:w-32 sm:h-32 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
@click="goToDetail"
|
||||||
|
>
|
||||||
<span class="text-gray-400 dark:text-gray-500 text-xs">Sin imagen</span>
|
<span class="text-gray-400 dark:text-gray-500 text-xs">Sin imagen</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +59,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-2" :title="article.title">
|
<h3
|
||||||
|
class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-2 cursor-pointer hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||||
|
:title="article.title"
|
||||||
|
@click="goToDetail"
|
||||||
|
>
|
||||||
{{ article.title || 'Sin título' }}
|
{{ article.title || 'Sin título' }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@@ -130,11 +142,14 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits, ref, onMounted, onUnmounted } from 'vue';
|
import { defineProps, defineEmits, ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { HeartIcon } from '@heroicons/vue/24/outline';
|
import { HeartIcon } from '@heroicons/vue/24/outline';
|
||||||
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
|
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
|
||||||
import authService from '../services/auth';
|
import authService from '../services/auth';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
article: {
|
article: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -158,7 +173,8 @@ const emit = defineEmits(['remove', 'added']);
|
|||||||
|
|
||||||
const isAdding = ref(false);
|
const isAdding = ref(false);
|
||||||
const isAuthenticated = ref(false);
|
const isAuthenticated = ref(false);
|
||||||
const favoriteStatus = ref(props.isFavorite);
|
// Usar is_favorite directamente del artículo, con fallback a props.isFavorite
|
||||||
|
const favoriteStatus = ref(props.article?.is_favorite ?? props.isFavorite);
|
||||||
|
|
||||||
// Verificar autenticación al montar y cuando cambie
|
// Verificar autenticación al montar y cuando cambie
|
||||||
function checkAuth() {
|
function checkAuth() {
|
||||||
@@ -218,10 +234,26 @@ async function handleAddFavorite() {
|
|||||||
|
|
||||||
function handleAuthChange() {
|
function handleAuthChange() {
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
// Actualizar favoriteStatus basado en el artículo (que viene del backend)
|
||||||
|
if (props.article) {
|
||||||
|
favoriteStatus.value = props.article.is_favorite ?? false;
|
||||||
|
} else {
|
||||||
|
favoriteStatus.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToDetail() {
|
||||||
|
if (props.article.platform && props.article.id) {
|
||||||
|
router.push(`/articles/${props.article.platform}/${props.article.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
// Inicializar favoriteStatus desde el artículo
|
||||||
|
if (props.article) {
|
||||||
|
favoriteStatus.value = props.article.is_favorite ?? false;
|
||||||
|
}
|
||||||
// Escuchar cambios en la autenticación
|
// Escuchar cambios en la autenticación
|
||||||
window.addEventListener('auth-login', handleAuthChange);
|
window.addEventListener('auth-login', handleAuthChange);
|
||||||
window.addEventListener('auth-logout', handleAuthChange);
|
window.addEventListener('auth-logout', handleAuthChange);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl p-3 max-w-sm min-w-[320px] backdrop-blur-sm"
|
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl p-3 max-w-sm min-w-[320px] backdrop-blur-sm cursor-pointer hover:shadow-2xl hover:scale-[1.02] transition-all duration-200"
|
||||||
|
@click="goToDetail"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
@@ -37,14 +38,15 @@
|
|||||||
:href="toast.url"
|
:href="toast.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
@click.stop
|
||||||
class="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline inline-flex items-center gap-1 font-medium"
|
class="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline inline-flex items-center gap-1 font-medium"
|
||||||
>
|
>
|
||||||
Ver artículo →
|
Ver artículo →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="$emit('close')"
|
@click.stop="$emit('close')"
|
||||||
class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-sm leading-none p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-sm leading-none p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors z-10"
|
||||||
title="Cerrar"
|
title="Cerrar"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -56,13 +58,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
toast: {
|
toast: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function goToDetail() {
|
||||||
|
if (props.toast.platform && props.toast.id) {
|
||||||
|
router.push(`/articles/${props.toast.platform}/${props.toast.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { createRouter, createWebHistory } from 'vue-router';
|
|||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import Dashboard from './views/Dashboard.vue';
|
import Dashboard from './views/Dashboard.vue';
|
||||||
import Articles from './views/Articles.vue';
|
import Articles from './views/Articles.vue';
|
||||||
|
import ArticleDetail from './views/ArticleDetail.vue';
|
||||||
import Favorites from './views/Favorites.vue';
|
import Favorites from './views/Favorites.vue';
|
||||||
import Workers from './views/Workers.vue';
|
import Workers from './views/Workers.vue';
|
||||||
import Users from './views/Users.vue';
|
import Users from './views/Users.vue';
|
||||||
import Logs from './views/Logs.vue';
|
import Logs from './views/Logs.vue';
|
||||||
|
import RateLimiter from './views/RateLimiter.vue';
|
||||||
|
import Sessions from './views/Sessions.vue';
|
||||||
import Login from './views/Login.vue';
|
import Login from './views/Login.vue';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
import authService from './services/auth';
|
import authService from './services/auth';
|
||||||
@@ -15,10 +18,13 @@ const routes = [
|
|||||||
{ path: '/login', component: Login, name: 'login' },
|
{ path: '/login', component: Login, name: 'login' },
|
||||||
{ path: '/', component: Dashboard, meta: { requiresAuth: true } },
|
{ path: '/', component: Dashboard, meta: { requiresAuth: true } },
|
||||||
{ path: '/articles', component: Articles, meta: { requiresAuth: true } },
|
{ path: '/articles', component: Articles, meta: { requiresAuth: true } },
|
||||||
|
{ path: '/articles/:platform/:id', component: ArticleDetail, meta: { requiresAuth: true } },
|
||||||
{ path: '/favorites', component: Favorites, meta: { requiresAuth: true } },
|
{ path: '/favorites', component: Favorites, meta: { requiresAuth: true } },
|
||||||
{ path: '/workers', component: Workers, meta: { requiresAuth: true } },
|
{ path: '/workers', component: Workers, meta: { requiresAuth: true } },
|
||||||
{ path: '/users', component: Users, meta: { requiresAuth: true } },
|
{ path: '/users', component: Users, meta: { requiresAuth: true } },
|
||||||
{ path: '/logs', component: Logs, meta: { requiresAuth: true } },
|
{ path: '/logs', component: Logs, meta: { requiresAuth: true } },
|
||||||
|
{ path: '/rate-limiter', component: RateLimiter, meta: { requiresAuth: true } },
|
||||||
|
{ path: '/sessions', component: Sessions, meta: { requiresAuth: true } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ export default {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getArticle(platform, id) {
|
||||||
|
const response = await api.get(`/articles/${platform}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
async getLogs(limit = 500, sinceLine = null) {
|
async getLogs(limit = 500, sinceLine = null) {
|
||||||
const params = { limit };
|
const params = { limit };
|
||||||
@@ -149,5 +154,22 @@ export default {
|
|||||||
const response = await api.post('/users/change-password', passwordData);
|
const response = await api.post('/users/change-password', passwordData);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Admin - Rate Limiter
|
||||||
|
async getRateLimiterInfo() {
|
||||||
|
const response = await api.get('/admin/rate-limiter');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin - Sessions
|
||||||
|
async getSessions() {
|
||||||
|
const response = await api.get('/admin/sessions');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSession(token) {
|
||||||
|
const response = await api.delete(`/admin/sessions/${token}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Servicio de autenticación para gestionar tokens
|
// Servicio de autenticación para gestionar tokens
|
||||||
|
import { getDeviceFingerprint } from './fingerprint.js';
|
||||||
|
|
||||||
const AUTH_STORAGE_KEY = 'wallabicher_token';
|
const AUTH_STORAGE_KEY = 'wallabicher_token';
|
||||||
const USERNAME_STORAGE_KEY = 'wallabicher_username';
|
const USERNAME_STORAGE_KEY = 'wallabicher_username';
|
||||||
@@ -109,12 +110,31 @@ class AuthService {
|
|||||||
// Hacer login (llamar al endpoint de login)
|
// Hacer login (llamar al endpoint de login)
|
||||||
async login(username, password) {
|
async login(username, password) {
|
||||||
try {
|
try {
|
||||||
|
// Obtener fingerprint del dispositivo
|
||||||
|
let fingerprintData = null;
|
||||||
|
try {
|
||||||
|
fingerprintData = await getDeviceFingerprint();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Error obteniendo fingerprint, continuando sin él:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agregar fingerprint si está disponible
|
||||||
|
if (fingerprintData) {
|
||||||
|
requestBody.fingerprint = fingerprintData.fingerprint;
|
||||||
|
requestBody.deviceInfo = fingerprintData.deviceInfo;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/users/login', {
|
const response = await fetch('/api/users/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
154
web/frontend/src/services/fingerprint.js
Normal file
154
web/frontend/src/services/fingerprint.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import FingerprintJS from '@fingerprintjs/fingerprintjs';
|
||||||
|
|
||||||
|
let fpPromise = null;
|
||||||
|
let cachedFingerprint = null;
|
||||||
|
let cachedDeviceInfo = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inicializa FingerprintJS (solo una vez)
|
||||||
|
*/
|
||||||
|
function initFingerprintJS() {
|
||||||
|
if (!fpPromise) {
|
||||||
|
fpPromise = FingerprintJS.load();
|
||||||
|
}
|
||||||
|
return fpPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el fingerprint del dispositivo
|
||||||
|
* @returns {Promise<{fingerprint: string, deviceInfo: Object}>}
|
||||||
|
*/
|
||||||
|
export async function getDeviceFingerprint() {
|
||||||
|
// Si ya tenemos el fingerprint en caché, devolverlo
|
||||||
|
if (cachedFingerprint && cachedDeviceInfo) {
|
||||||
|
return {
|
||||||
|
fingerprint: cachedFingerprint,
|
||||||
|
deviceInfo: cachedDeviceInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fp = await initFingerprintJS();
|
||||||
|
const result = await fp.get();
|
||||||
|
|
||||||
|
// Extraer información del dispositivo desde los componentes
|
||||||
|
const deviceInfo = extractDeviceInfo(result.components);
|
||||||
|
|
||||||
|
cachedFingerprint = result.visitorId;
|
||||||
|
cachedDeviceInfo = deviceInfo;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fingerprint: result.visitorId,
|
||||||
|
deviceInfo: deviceInfo,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error obteniendo fingerprint:', error);
|
||||||
|
// Fallback: generar un fingerprint básico
|
||||||
|
return {
|
||||||
|
fingerprint: generateFallbackFingerprint(),
|
||||||
|
deviceInfo: {
|
||||||
|
browser: navigator.userAgent.includes('Chrome') ? 'Chrome' :
|
||||||
|
navigator.userAgent.includes('Firefox') ? 'Firefox' :
|
||||||
|
navigator.userAgent.includes('Safari') ? 'Safari' : 'Unknown',
|
||||||
|
os: navigator.platform,
|
||||||
|
device: 'Unknown',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrae información legible del dispositivo desde los componentes de FingerprintJS
|
||||||
|
* @param {Object} components - Componentes de FingerprintJS
|
||||||
|
* @returns {Object} Información del dispositivo
|
||||||
|
*/
|
||||||
|
function extractDeviceInfo(components) {
|
||||||
|
const info = {
|
||||||
|
browser: 'Unknown',
|
||||||
|
browserVersion: '',
|
||||||
|
os: 'Unknown',
|
||||||
|
osVersion: '',
|
||||||
|
device: 'Unknown',
|
||||||
|
screenResolution: '',
|
||||||
|
timezone: '',
|
||||||
|
language: navigator.language || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Información del navegador
|
||||||
|
if (components.browserName) {
|
||||||
|
info.browser = components.browserName.value || 'Unknown';
|
||||||
|
}
|
||||||
|
if (components.browserVersion) {
|
||||||
|
info.browserVersion = components.browserVersion.value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Información del sistema operativo
|
||||||
|
if (components.os) {
|
||||||
|
info.os = components.os.value || 'Unknown';
|
||||||
|
}
|
||||||
|
if (components.osVersion) {
|
||||||
|
info.osVersion = components.osVersion.value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Información del dispositivo
|
||||||
|
if (components.deviceMemory) {
|
||||||
|
info.device = components.deviceMemory.value ? 'Desktop' : 'Mobile';
|
||||||
|
}
|
||||||
|
if (components.platform) {
|
||||||
|
const platform = components.platform.value?.toLowerCase() || '';
|
||||||
|
if (platform.includes('mobile') || platform.includes('android') || platform.includes('iphone')) {
|
||||||
|
info.device = 'Mobile';
|
||||||
|
} else if (platform.includes('tablet') || platform.includes('ipad')) {
|
||||||
|
info.device = 'Tablet';
|
||||||
|
} else {
|
||||||
|
info.device = 'Desktop';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolución de pantalla
|
||||||
|
if (components.screenResolution) {
|
||||||
|
const res = components.screenResolution.value;
|
||||||
|
if (res && res.length >= 2) {
|
||||||
|
info.screenResolution = `${res[0]}x${res[1]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zona horaria
|
||||||
|
if (components.timezone) {
|
||||||
|
info.timezone = components.timezone.value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera un fingerprint básico como fallback
|
||||||
|
* @returns {string} Hash del fingerprint
|
||||||
|
*/
|
||||||
|
function generateFallbackFingerprint() {
|
||||||
|
const data = [
|
||||||
|
navigator.userAgent,
|
||||||
|
navigator.language,
|
||||||
|
navigator.platform,
|
||||||
|
screen.width + 'x' + screen.height,
|
||||||
|
new Date().getTimezoneOffset(),
|
||||||
|
].join('|');
|
||||||
|
|
||||||
|
// Simple hash (no usar en producción, solo como fallback)
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const char = data.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia el caché del fingerprint (útil para testing)
|
||||||
|
*/
|
||||||
|
export function clearFingerprintCache() {
|
||||||
|
cachedFingerprint = null;
|
||||||
|
cachedDeviceInfo = null;
|
||||||
|
}
|
||||||
|
|
||||||
476
web/frontend/src/views/ArticleDetail.vue
Normal file
476
web/frontend/src/views/ArticleDetail.vue
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<!-- Botón de volver -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<button
|
||||||
|
@click="$router.back()"
|
||||||
|
class="btn btn-secondary flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon class="w-4 h-4" />
|
||||||
|
Volver
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="card text-center py-12">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando artículo...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="error" class="card text-center py-12">
|
||||||
|
<ExclamationTriangleIcon class="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||||
|
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{{ error }}</p>
|
||||||
|
<button @click="$router.back()" class="btn btn-secondary mt-4">Volver</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenido del artículo -->
|
||||||
|
<div v-else-if="article" class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<!-- Columna izquierda: Carousel de imágenes -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="card p-0 overflow-hidden">
|
||||||
|
<!-- Carousel principal -->
|
||||||
|
<div v-if="article.images && article.images.length > 0" class="relative">
|
||||||
|
<!-- Imagen principal -->
|
||||||
|
<div class="relative aspect-[4/3] bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
||||||
|
<img
|
||||||
|
:src="article.images[currentImageIndex]"
|
||||||
|
:alt="`Imagen ${currentImageIndex + 1} de ${article.title}`"
|
||||||
|
class="w-full h-full object-contain transition-opacity duration-300"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Botones de navegación -->
|
||||||
|
<button
|
||||||
|
v-if="article.images.length > 1"
|
||||||
|
@click="previousImage"
|
||||||
|
class="absolute left-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-all z-10"
|
||||||
|
:disabled="article.images.length <= 1"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="article.images.length > 1"
|
||||||
|
@click="nextImage"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-all z-10"
|
||||||
|
:disabled="article.images.length <= 1"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Indicador de posición -->
|
||||||
|
<div
|
||||||
|
v-if="article.images.length > 1"
|
||||||
|
class="absolute bottom-3 left-1/2 -translate-x-1/2 bg-black/50 text-white px-3 py-1 rounded-full text-xs font-medium z-10"
|
||||||
|
>
|
||||||
|
{{ currentImageIndex + 1 }} / {{ article.images.length }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botón para ampliar -->
|
||||||
|
<button
|
||||||
|
@click="openImageModal(article.images[currentImageIndex])"
|
||||||
|
class="absolute top-3 right-3 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-all z-10"
|
||||||
|
>
|
||||||
|
<MagnifyingGlassPlusIcon class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Miniaturas -->
|
||||||
|
<div
|
||||||
|
v-if="article.images.length > 1"
|
||||||
|
class="p-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
|
||||||
|
<button
|
||||||
|
v-for="(image, index) in article.images"
|
||||||
|
:key="index"
|
||||||
|
@click="currentImageIndex = index"
|
||||||
|
class="flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all"
|
||||||
|
:class="
|
||||||
|
currentImageIndex === index
|
||||||
|
? 'border-primary-600 dark:border-primary-400 ring-2 ring-primary-200 dark:ring-primary-800'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="image"
|
||||||
|
:alt="`Miniatura ${index + 1}`"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="aspect-[4/3] bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Sin imágenes disponibles</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Columna derecha: Información del artículo -->
|
||||||
|
<div class="lg:col-span-1 space-y-4">
|
||||||
|
<!-- Header compacto -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<span
|
||||||
|
class="px-2 py-1 text-xs font-semibold rounded"
|
||||||
|
:class="
|
||||||
|
article.platform === 'wallapop'
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300'
|
||||||
|
: 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-300'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ article.platform?.toUpperCase() || 'N/A' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="article.username" class="px-2 py-1 text-xs font-medium rounded bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300">
|
||||||
|
👤 {{ article.username }}
|
||||||
|
</span>
|
||||||
|
<span v-if="article.worker_name" class="px-2 py-1 text-xs font-medium rounded bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-300">
|
||||||
|
⚙️ {{ article.worker_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-3 line-clamp-2">
|
||||||
|
{{ article.title || 'Sin título' }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div v-if="article.price !== null && article.price !== undefined" class="mb-4">
|
||||||
|
<span class="text-3xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
{{ article.price }} {{ article.currency || '€' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<a
|
||||||
|
v-if="article.url"
|
||||||
|
:href="article.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn btn-primary flex items-center justify-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<ArrowTopRightOnSquareIcon class="w-4 h-4" />
|
||||||
|
Ver anuncio original
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
v-if="isAuthenticated && !isAdding"
|
||||||
|
@click="handleAddFavorite"
|
||||||
|
class="btn flex items-center justify-center gap-2 text-sm"
|
||||||
|
:class="favoriteStatus ? 'btn-secondary' : 'bg-pink-500 hover:bg-pink-600 text-white border-pink-600'"
|
||||||
|
:disabled="favoriteStatus"
|
||||||
|
>
|
||||||
|
<HeartIconSolid v-if="favoriteStatus" class="w-4 h-4" />
|
||||||
|
<HeartIcon v-else class="w-4 h-4" />
|
||||||
|
{{ favoriteStatus ? 'En favoritos' : 'Añadir a favoritos' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isAuthenticated && isAdding"
|
||||||
|
disabled
|
||||||
|
class="btn btn-secondary opacity-50 cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<span class="inline-block animate-spin mr-1">⏳</span>
|
||||||
|
Añadiendo...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Información compacta -->
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Información</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-if="article.location" class="flex items-start gap-2">
|
||||||
|
<MapPinIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Localidad</p>
|
||||||
|
<p class="text-sm text-gray-900 dark:text-gray-100 truncate">{{ article.location }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="article.allows_shipping !== null && article.allows_shipping !== undefined" class="flex items-start gap-2">
|
||||||
|
<TruckIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Envío</p>
|
||||||
|
<p class="text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{{ article.allows_shipping ? '✅ Acepta envíos' : '❌ No acepta envíos' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="article.modified_at" class="flex items-start gap-2">
|
||||||
|
<ClockIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Modificado</p>
|
||||||
|
<p class="text-xs text-gray-900 dark:text-gray-100">{{ formatDateShort(article.modified_at) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="article.notifiedAt" class="flex items-start gap-2">
|
||||||
|
<BellIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Notificado</p>
|
||||||
|
<p class="text-xs text-gray-900 dark:text-gray-100">{{ formatDateShort(article.notifiedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<HashtagIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">ID</p>
|
||||||
|
<p class="text-xs text-gray-900 dark:text-gray-100 font-mono truncate">{{ article.id }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Descripción (debajo del grid principal) -->
|
||||||
|
<div v-if="article && article.description" class="mt-4">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Descripción</h2>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||||
|
{{ article.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de imagen -->
|
||||||
|
<div
|
||||||
|
v-if="selectedImage"
|
||||||
|
@click="selectedImage = null"
|
||||||
|
class="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div class="relative max-w-6xl max-h-full w-full" @click.stop>
|
||||||
|
<button
|
||||||
|
@click="selectedImage = null"
|
||||||
|
class="absolute -top-12 right-0 text-white hover:text-gray-300 transition-colors z-10"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="w-8 h-8" />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
:src="selectedImage"
|
||||||
|
alt="Imagen ampliada"
|
||||||
|
class="max-w-full max-h-[90vh] object-contain rounded-lg mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import api from '../services/api';
|
||||||
|
import authService from '../services/auth';
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
ArrowTopRightOnSquareIcon,
|
||||||
|
HeartIcon,
|
||||||
|
MagnifyingGlassPlusIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
TruckIcon,
|
||||||
|
ClockIcon,
|
||||||
|
BellIcon,
|
||||||
|
HashtagIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const article = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref(null);
|
||||||
|
const selectedImage = ref(null);
|
||||||
|
const currentImageIndex = ref(0);
|
||||||
|
const isAuthenticated = ref(false);
|
||||||
|
const favoriteStatus = ref(false);
|
||||||
|
const isAdding = ref(false);
|
||||||
|
|
||||||
|
function formatDateShort(timestamp) {
|
||||||
|
if (!timestamp) return 'N/A';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleDateString('es-ES', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp) {
|
||||||
|
if (!timestamp) return 'N/A';
|
||||||
|
return new Date(timestamp).toLocaleString('es-ES', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageError(event) {
|
||||||
|
event.target.onerror = null;
|
||||||
|
event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgdmlld0JveD0iMCAwIDEyOCAxMjgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik00OCA0OEg4ME04MCA4MEg0OE00OCA0OEw2NCA2NEw4MCA0OE00OCA4MEw2NCA2NE04MCA4MEw2NCA2NEw0OCA4MCIgc3Ryb2tlPSIjOUI5Q0E0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImageModal(image) {
|
||||||
|
selectedImage.value = image;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextImage() {
|
||||||
|
if (article.value?.images && article.value.images.length > 0) {
|
||||||
|
currentImageIndex.value = (currentImageIndex.value + 1) % article.value.images.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousImage() {
|
||||||
|
if (article.value?.images && article.value.images.length > 0) {
|
||||||
|
currentImageIndex.value = currentImageIndex.value === 0
|
||||||
|
? article.value.images.length - 1
|
||||||
|
: currentImageIndex.value - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event) {
|
||||||
|
if (!article.value?.images || article.value.images.length <= 1) return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault();
|
||||||
|
previousImage();
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault();
|
||||||
|
nextImage();
|
||||||
|
} else if (event.key === 'Escape' && selectedImage.value) {
|
||||||
|
selectedImage.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAuth() {
|
||||||
|
isAuthenticated.value = authService.hasCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddFavorite() {
|
||||||
|
if (!isAuthenticated.value || favoriteStatus.value || isAdding.value || !article.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdding.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const favorite = {
|
||||||
|
platform: article.value.platform,
|
||||||
|
id: String(article.value.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
await api.addFavorite(favorite);
|
||||||
|
favoriteStatus.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error añadiendo a favoritos:', error);
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
alert('El artículo no se encontró en la base de datos.');
|
||||||
|
} else if (error.response?.status === 400) {
|
||||||
|
alert('Error: ' + (error.response?.data?.error || 'Datos inválidos'));
|
||||||
|
} else if (error.response?.status !== 401) {
|
||||||
|
const errorMessage = error.response?.data?.error || error.message || 'Error desconocido';
|
||||||
|
alert('Error al añadir a favoritos: ' + errorMessage);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isAdding.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthChange() {
|
||||||
|
checkAuth();
|
||||||
|
// Actualizar favoriteStatus basado en el artículo (que viene del backend)
|
||||||
|
if (article.value) {
|
||||||
|
favoriteStatus.value = article.value.is_favorite ?? false;
|
||||||
|
} else {
|
||||||
|
favoriteStatus.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArticle() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const platform = route.params.platform;
|
||||||
|
const id = route.params.id;
|
||||||
|
|
||||||
|
if (!platform || !id) {
|
||||||
|
error.value = 'Parámetros inválidos';
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
article.value = await api.getArticle(platform, id);
|
||||||
|
|
||||||
|
if (!article.value) {
|
||||||
|
error.value = 'Artículo no encontrado';
|
||||||
|
} else {
|
||||||
|
currentImageIndex.value = 0;
|
||||||
|
checkAuth();
|
||||||
|
// Actualizar favoriteStatus desde el artículo (que viene del backend con is_favorite)
|
||||||
|
favoriteStatus.value = article.value.is_favorite ?? false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error cargando artículo:', err);
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
error.value = 'Artículo no encontrado';
|
||||||
|
} else if (err.response?.status === 403) {
|
||||||
|
error.value = 'No tienes permiso para ver este artículo';
|
||||||
|
} else {
|
||||||
|
error.value = 'Error al cargar el artículo: ' + (err.message || 'Error desconocido');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadArticle();
|
||||||
|
checkAuth();
|
||||||
|
window.addEventListener('auth-login', handleAuthChange);
|
||||||
|
window.addEventListener('auth-logout', handleAuthChange);
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('auth-login', handleAuthChange);
|
||||||
|
window.removeEventListener('auth-logout', handleAuthChange);
|
||||||
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 inline-flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary-600 hover:bg-primary-700 text-white border border-primary-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
209
web/frontend/src/views/RateLimiter.vue
Normal file
209
web/frontend/src/views/RateLimiter.vue
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Rate Limiter</h1>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
@click="loadRateLimiterInfo(true)"
|
||||||
|
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
|
||||||
|
>
|
||||||
|
🔄 Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="accessDenied || (!isAdmin && currentUser)" class="card text-center py-12">
|
||||||
|
<ShieldExclamationIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold">Acceso Denegado</p>
|
||||||
|
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||||
|
Solo los administradores pueden ver el rate limiter
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- Estadísticas -->
|
||||||
|
<div v-if="rateLimiterInfo && rateLimiterInfo.enabled" class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total de Claves</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ rateLimiterInfo.stats?.totalBlocks || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Bloqueos Activos</div>
|
||||||
|
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||||
|
{{ rateLimiterInfo.stats?.activeBlocks || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Tipo</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100 capitalize">
|
||||||
|
{{ rateLimiterInfo.type }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuración -->
|
||||||
|
<div v-if="rateLimiterInfo && rateLimiterInfo.config" class="card p-4 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Configuración</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Puntos</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ rateLimiterInfo.config.points }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Duración (seg)</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ rateLimiterInfo.config.duration }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Duración Bloqueo (seg)</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ rateLimiterInfo.config.blockDuration }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensaje si no está habilitado -->
|
||||||
|
<div v-if="rateLimiterInfo && !rateLimiterInfo.enabled" class="card p-6 text-center">
|
||||||
|
<ShieldExclamationIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold">Rate Limiter Deshabilitado</p>
|
||||||
|
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||||
|
{{ rateLimiterInfo.message || 'El rate limiter no está configurado' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nota para rate limiter en memoria -->
|
||||||
|
<div v-if="rateLimiterInfo && rateLimiterInfo.note" class="card p-4 mb-6 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
|
||||||
|
<p class="text-sm text-yellow-800 dark:text-yellow-200">{{ rateLimiterInfo.note }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de bloqueos -->
|
||||||
|
<div v-if="rateLimiterInfo && rateLimiterInfo.blocks && rateLimiterInfo.blocks.length > 0" class="card p-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Bloqueos</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Clave/IP
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Puntos Restantes
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Total Hits
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Tiempo Restante
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<tr v-for="block in rateLimiterInfo.blocks" :key="block.key">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900 dark:text-gray-100">
|
||||||
|
{{ block.key }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
:class="block.isBlocked
|
||||||
|
? 'px-2 py-1 text-xs font-semibold rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
||||||
|
: 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
|
||||||
|
>
|
||||||
|
{{ block.isBlocked ? 'Bloqueado' : 'Activo' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{{ block.remainingPoints }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{{ block.totalHits }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatTimeRemaining(block.msBeforeNext) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="rateLimiterInfo && rateLimiterInfo.enabled" class="card p-6 text-center">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">No hay bloqueos registrados</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card p-6 text-center">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando información del rate limiter...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import api from '../services/api';
|
||||||
|
import authService from '../services/auth';
|
||||||
|
import { ShieldExclamationIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
|
const rateLimiterInfo = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const currentUser = ref(authService.getUsername() || null);
|
||||||
|
const isAdmin = ref(false);
|
||||||
|
const accessDenied = ref(false);
|
||||||
|
|
||||||
|
function formatTimeRemaining(ms) {
|
||||||
|
if (!ms || ms <= 0) return 'N/A';
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes % 60}m`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds % 60}s`;
|
||||||
|
} else {
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRateLimiterInfo(showLoading = false) {
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
accessDenied.value = true;
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accessDenied.value = false;
|
||||||
|
|
||||||
|
if (showLoading) {
|
||||||
|
loading.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.getRateLimiterInfo();
|
||||||
|
rateLimiterInfo.value = data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cargando información del rate limiter:', error);
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
accessDenied.value = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
isAdmin.value = authService.isAdmin();
|
||||||
|
loadRateLimiterInfo(true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
261
web/frontend/src/views/Sessions.vue
Normal file
261
web/frontend/src/views/Sessions.vue
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Sesiones</h1>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
@click="loadSessions(true)"
|
||||||
|
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
|
||||||
|
>
|
||||||
|
🔄 Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="accessDenied || (!isAdmin && currentUser)" class="card text-center py-12">
|
||||||
|
<UserGroupIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold">Acceso Denegado</p>
|
||||||
|
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||||
|
Solo los administradores pueden ver las sesiones
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- Estadísticas -->
|
||||||
|
<div v-if="sessionsData && sessionsData.stats" class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Sesiones</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ sessionsData.stats.total || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Sesiones Activas</div>
|
||||||
|
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{{ sessionsData.stats.active || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Sesiones Expiradas</div>
|
||||||
|
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||||
|
{{ sessionsData.stats.expired || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Usuarios Únicos</div>
|
||||||
|
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{{ Object.keys(sessionsData.stats.byUser || {}).length }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sesiones por usuario -->
|
||||||
|
<div v-if="sessionsData && sessionsData.stats && sessionsData.stats.byUser && Object.keys(sessionsData.stats.byUser).length > 0" class="card p-4 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Sesiones por Usuario</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
<div
|
||||||
|
v-for="(count, username) in sessionsData.stats.byUser"
|
||||||
|
:key="username"
|
||||||
|
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-gray-100">{{ username }}</span>
|
||||||
|
<span class="px-2 py-1 text-sm font-semibold rounded bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
|
||||||
|
{{ count }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de sesiones -->
|
||||||
|
<div v-if="sessionsData && sessionsData.sessions && sessionsData.sessions.length > 0" class="card p-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Todas las Sesiones</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Usuario
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Dispositivo
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Token
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Creada
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Expira
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<tr v-for="session in sessionsData.sessions" :key="session.token">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ session.username }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
<div v-if="session.deviceInfo" class="space-y-1">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ formatDeviceInfo(session.deviceInfo) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ session.deviceInfo.os || 'Unknown OS' }}
|
||||||
|
<span v-if="session.deviceInfo.osVersion"> {{ session.deviceInfo.osVersion }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="session.deviceInfo.ip" class="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||||
|
IP: {{ session.deviceInfo.ip }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-400 dark:text-gray-500 italic">
|
||||||
|
Sin información
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm font-mono text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="truncate max-w-xs inline-block" :title="session.token">
|
||||||
|
{{ session.token.substring(0, 16) }}...
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatDate(session.createdAt) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatDate(session.expiresAt) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
:class="session.isExpired
|
||||||
|
? 'px-2 py-1 text-xs font-semibold rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
||||||
|
: 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
|
||||||
|
>
|
||||||
|
{{ session.isExpired ? 'Expirada' : 'Activa' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||||
|
<button
|
||||||
|
@click="confirmDeleteSession(session.token)"
|
||||||
|
class="btn btn-danger text-xs"
|
||||||
|
:disabled="session.isExpired"
|
||||||
|
title="Eliminar sesión"
|
||||||
|
>
|
||||||
|
🗑️ Eliminar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="sessionsData && sessionsData.sessions && sessionsData.sessions.length === 0" class="card p-6 text-center">
|
||||||
|
<UserGroupIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">No hay sesiones registradas</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card p-6 text-center">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando sesiones...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import api from '../services/api';
|
||||||
|
import authService from '../services/auth';
|
||||||
|
import { UserGroupIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
|
const sessionsData = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const currentUser = ref(authService.getUsername() || null);
|
||||||
|
const isAdmin = ref(false);
|
||||||
|
const accessDenied = ref(false);
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('es-ES', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDeviceInfo(deviceInfo) {
|
||||||
|
if (!deviceInfo) return 'Unknown';
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (deviceInfo.browser) {
|
||||||
|
parts.push(deviceInfo.browser);
|
||||||
|
if (deviceInfo.browserVersion) {
|
||||||
|
parts.push(deviceInfo.browserVersion.split('.')[0]); // Solo versión mayor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceInfo.device && deviceInfo.device !== 'Desktop') {
|
||||||
|
parts.push(`(${deviceInfo.device})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ') || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessions(showLoading = false) {
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
accessDenied.value = true;
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accessDenied.value = false;
|
||||||
|
|
||||||
|
if (showLoading) {
|
||||||
|
loading.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.getSessions();
|
||||||
|
sessionsData.value = data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cargando sesiones:', error);
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
accessDenied.value = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteSession(token) {
|
||||||
|
if (!confirm('¿Estás seguro de que quieres eliminar esta sesión?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteSession(token);
|
||||||
|
// Recargar sesiones después de eliminar
|
||||||
|
await loadSessions(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error eliminando sesión:', error);
|
||||||
|
alert('Error al eliminar la sesión: ' + (error.response?.data?.error || error.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
isAdmin.value = authService.isAdmin();
|
||||||
|
loadSessions(true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
Reference in New Issue
Block a user