diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue
index dce5ecd..dcc2d77 100644
--- a/web/frontend/src/App.vue
+++ b/web/frontend/src/App.vue
@@ -176,11 +176,14 @@ import {
BellSlashIcon,
ArrowRightOnRectangleIcon,
} from '@heroicons/vue/24/outline';
-import pushNotificationService from './services/pushNotifications';
-import authService from './services/auth';
+import pushNotificationService from './application/services/PushNotificationService.js';
+import authService from './application/services/AuthService.js';
+import webSocketService from './core/websocket/WebSocketService.js';
import { useRouter } from 'vue-router';
-import api from './services/api';
-import ToastContainer from './components/ToastContainer.vue';
+import { useAuth } from './presentation/composables/useAuth.js';
+import { useWebSocket } from './presentation/composables/useWebSocket.js';
+import { useDarkMode } from './presentation/composables/useDarkMode.js';
+import ToastContainer from './presentation/components/ToastContainer.vue';
const allNavItems = [
{ path: '/', name: 'Dashboard', icon: HomeIcon, adminOnly: false },
@@ -194,16 +197,11 @@ const allNavItems = [
];
const router = useRouter();
-const wsConnected = ref(false);
+const { currentUser, isAuthenticated, isAdmin, username } = useAuth();
+const { isConnected: wsConnected } = useWebSocket();
+const { isDark, toggle: toggleDarkMode } = useDarkMode();
const sidebarCollapsed = ref(false);
-const darkMode = ref(false);
const pushEnabled = ref(false);
-const currentUser = ref(authService.getUsername() || null);
-const isAdmin = ref(false);
-let ws = null;
-
-const isDark = computed(() => darkMode.value);
-const isAuthenticated = computed(() => authService.hasCredentials());
// Filtrar navItems según el rol del usuario
const navItems = computed(() => {
@@ -217,27 +215,6 @@ const navItems = computed(() => {
});
-function toggleDarkMode() {
- darkMode.value = !darkMode.value;
- if (darkMode.value) {
- document.documentElement.classList.add('dark');
- localStorage.setItem('darkMode', 'true');
- } else {
- document.documentElement.classList.remove('dark');
- localStorage.setItem('darkMode', 'false');
- }
-}
-
-function initDarkMode() {
- const saved = localStorage.getItem('darkMode');
- if (saved === 'true' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
- darkMode.value = true;
- document.documentElement.classList.add('dark');
- } else {
- darkMode.value = false;
- document.documentElement.classList.remove('dark');
- }
-}
async function enablePushNotifications() {
try {
@@ -287,60 +264,35 @@ function getCurrentPageTitle() {
}
function handleAuthChange() {
- currentUser.value = authService.getUsername() || null;
- isAdmin.value = authService.isAdmin();
- // Reconectar websocket cuando cambie la autenticación (login)
- if (authService.hasCredentials()) {
- connectWebSocket();
+ // WebSocket will be connected automatically by AuthService
+ const token = authService.getToken();
+ if (token) {
+ webSocketService.connect(token);
}
}
async function handleLogout() {
- // Cerrar conexión WebSocket antes de hacer logout
- if (ws) {
- ws.close();
- ws = null;
- wsConnected.value = false;
- }
-
- // Llamar al endpoint de logout e invalidar token
await authService.logout();
-
- // Redirigir a login después del logout
router.push('/login');
-
- // Disparar evento para que los componentes se actualicen
- window.dispatchEvent(new CustomEvent('auth-logout'));
-
- // Mostrar mensaje informativo
- console.log('Sesión cerrada correctamente');
}
onMounted(async () => {
- initDarkMode();
- currentUser.value = authService.getUsername() || null;
- isAdmin.value = authService.isAdmin();
await checkPushStatus();
// Escuchar eventos de autenticación
window.addEventListener('auth-login', handleAuthChange);
window.addEventListener('auth-logout', handleAuthChange);
- // Si hay credenciales, validar y conectar websocket
+ // Escuchar eventos de WebSocket
+ window.addEventListener('ws-connected', () => {
+ // Connection state is managed by useWebSocket composable
+ });
+
+ // Si hay credenciales, conectar websocket
if (authService.hasCredentials()) {
- // Validar si el token sigue siendo válido
- const isValid = await authService.validateSession();
- if (!isValid) {
- // Si el token expiró, limpiar sesión y redirigir a login
- authService.clearSession();
- currentUser.value = authService.getUsername() || null;
- isAdmin.value = authService.isAdmin();
- if (router.currentRoute.value.path !== '/login') {
- router.push('/login');
- }
- } else {
- // Solo conectar websocket si el token es válido
- connectWebSocket();
+ const token = authService.getToken();
+ if (token) {
+ webSocketService.connect(token);
}
}
});
@@ -348,86 +300,6 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('auth-login', handleAuthChange);
window.removeEventListener('auth-logout', handleAuthChange);
-
- if (ws) {
- ws.close();
- }
});
-
-function connectWebSocket() {
- // Cerrar conexión existente si hay una
- if (ws) {
- ws.close();
- ws = null;
- }
-
- // Verificar si hay token de autenticación
- const token = authService.getToken();
- if (!token) {
- console.log('No hay token de autenticación, no se conectará WebSocket');
- wsConnected.value = false;
- return;
- }
-
- let wsUrl;
-
- // Si hay una URL de API configurada, usarla para WebSocket también
- const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
- if (apiBaseUrl && apiBaseUrl !== '/api') {
- // Extraer el host de la URL de la API y construir la URL del WebSocket
- try {
- const url = new URL(apiBaseUrl);
- const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
- wsUrl = `${protocol}//${url.host}/ws?token=${encodeURIComponent(token)}`;
- } catch (e) {
- // Si falla el parsing, usar la configuración por defecto
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
- }
- } else {
- // Use relative path so Vite proxy can handle it
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
- }
-
- ws = new WebSocket(wsUrl);
-
- ws.onopen = () => {
- wsConnected.value = true;
- console.log('WebSocket conectado');
- };
-
- ws.onclose = (event) => {
- wsConnected.value = false;
-
- // Si el cierre fue por autenticación fallida (código 1008), no reintentar
- if (event.code === 1008) {
- console.log('WebSocket cerrado: autenticación fallida');
- // Si el token aún existe, intentar reconectar después de un delay más largo
- // para dar tiempo a que el usuario se autentique de nuevo
- if (authService.hasCredentials()) {
- setTimeout(connectWebSocket, 5000);
- }
- } else {
- // Para otros errores, reintentar después de 3 segundos si hay token
- if (authService.hasCredentials()) {
- console.log('WebSocket desconectado, reintentando...');
- setTimeout(connectWebSocket, 3000);
- }
- }
- };
-
- ws.onerror = (error) => {
- console.error('Error WebSocket:', error);
- wsConnected.value = false;
- };
-
- ws.onmessage = (event) => {
- const data = JSON.parse(event.data);
-
- // Los componentes individuales manejarán los mensajes (incluyendo ToastContainer)
- window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
- };
-}
diff --git a/web/frontend/src/application/services/AdminService.js b/web/frontend/src/application/services/AdminService.js
new file mode 100644
index 0000000..5b68d42
--- /dev/null
+++ b/web/frontend/src/application/services/AdminService.js
@@ -0,0 +1,17 @@
+import adminRepository from '../../domain/repositories/AdminRepository.js';
+
+class AdminService {
+ async getRateLimiterInfo() {
+ return await adminRepository.getRateLimiterInfo();
+ }
+
+ async getSessions() {
+ return await adminRepository.getSessions();
+ }
+
+ async deleteSession(token) {
+ await adminRepository.deleteSession(token);
+ }
+}
+
+export default new AdminService();
diff --git a/web/frontend/src/application/services/ArticleService.js b/web/frontend/src/application/services/ArticleService.js
new file mode 100644
index 0000000..f9e1962
--- /dev/null
+++ b/web/frontend/src/application/services/ArticleService.js
@@ -0,0 +1,21 @@
+import articleRepository from '../../domain/repositories/ArticleRepository.js';
+
+class ArticleService {
+ async getArticles(limit = 100, offset = 0, filters = {}) {
+ return await articleRepository.getArticles(limit, offset, filters);
+ }
+
+ async getArticleFacets() {
+ return await articleRepository.getArticleFacets();
+ }
+
+ async searchArticles(query, mode = 'AND') {
+ return await articleRepository.searchArticles(query, mode);
+ }
+
+ async getArticle(platform, id) {
+ return await articleRepository.getArticle(platform, id);
+ }
+}
+
+export default new ArticleService();
diff --git a/web/frontend/src/application/services/AuthService.js b/web/frontend/src/application/services/AuthService.js
new file mode 100644
index 0000000..f55d52e
--- /dev/null
+++ b/web/frontend/src/application/services/AuthService.js
@@ -0,0 +1,124 @@
+import authRepository from '../../domain/repositories/AuthRepository.js';
+import storageService from '../../core/storage/StorageService.js';
+import { User } from '../../domain/entities/User.js';
+import apiClient from '../../core/http/ApiClient.js';
+import webSocketService from '../../core/websocket/WebSocketService.js';
+
+const AUTH_STORAGE_KEY = 'wallabicher_auth';
+
+class AuthService {
+ constructor() {
+ this.currentUser = this.loadUser();
+ this.setupApiClient();
+ }
+
+ loadUser() {
+ const authData = storageService.get(AUTH_STORAGE_KEY);
+ if (authData && authData.token) {
+ return new User(authData);
+ }
+ return null;
+ }
+
+ setupApiClient() {
+ if (this.currentUser?.token) {
+ apiClient.setAuthToken(this.currentUser.token);
+ }
+ }
+
+ async login(username, password) {
+ const data = await authRepository.login(username, password);
+
+ if (data.success && data.token) {
+ const user = new User({
+ username: data.username,
+ role: data.role || 'user',
+ token: data.token,
+ });
+
+ this.currentUser = user;
+ storageService.set(AUTH_STORAGE_KEY, user.toJSON());
+ apiClient.setAuthToken(user.token);
+ webSocketService.connect(user.token);
+
+ window.dispatchEvent(new CustomEvent('auth-login', { detail: user }));
+
+ return user;
+ }
+
+ throw new Error('Respuesta inválida del servidor');
+ }
+
+ async logout() {
+ if (this.currentUser?.token) {
+ try {
+ await authRepository.logout(this.currentUser.token);
+ } catch (error) {
+ console.error('Error al cerrar sesión en el servidor:', error);
+ }
+ }
+
+ this.currentUser = null;
+ storageService.remove(AUTH_STORAGE_KEY);
+ apiClient.clearAuthToken();
+ webSocketService.close();
+
+ window.dispatchEvent(new CustomEvent('auth-logout'));
+ }
+
+ async validateSession() {
+ if (!this.currentUser?.token) {
+ return false;
+ }
+
+ const data = await authRepository.validateSession(this.currentUser.token);
+
+ if (data) {
+ if (data.role) {
+ this.currentUser.role = data.role;
+ storageService.set(AUTH_STORAGE_KEY, this.currentUser.toJSON());
+ }
+ return true;
+ }
+
+ this.clearSession();
+ return false;
+ }
+
+ clearSession() {
+ this.currentUser = null;
+ storageService.remove(AUTH_STORAGE_KEY);
+ apiClient.clearAuthToken();
+ webSocketService.close();
+ }
+
+ getCurrentUser() {
+ return this.currentUser;
+ }
+
+ getToken() {
+ return this.currentUser?.token || null;
+ }
+
+ getUsername() {
+ return this.currentUser?.username || null;
+ }
+
+ getRole() {
+ return this.currentUser?.role || null;
+ }
+
+ isAdmin() {
+ return this.currentUser?.isAdmin || false;
+ }
+
+ hasCredentials() {
+ return !!this.currentUser?.token;
+ }
+
+ getAuthHeader() {
+ return this.currentUser?.token ? `Bearer ${this.currentUser.token}` : null;
+ }
+}
+
+export default new AuthService();
diff --git a/web/frontend/src/application/services/FavoriteService.js b/web/frontend/src/application/services/FavoriteService.js
new file mode 100644
index 0000000..510e0f7
--- /dev/null
+++ b/web/frontend/src/application/services/FavoriteService.js
@@ -0,0 +1,19 @@
+import favoriteRepository from '../../domain/repositories/FavoriteRepository.js';
+import { Favorite } from '../../domain/entities/Favorite.js';
+
+class FavoriteService {
+ async getFavorites() {
+ return await favoriteRepository.getFavorites();
+ }
+
+ async addFavorite(platform, id) {
+ const favorite = new Favorite({ platform, id });
+ return await favoriteRepository.addFavorite(favorite);
+ }
+
+ async removeFavorite(platform, id) {
+ await favoriteRepository.removeFavorite(platform, id);
+ }
+}
+
+export default new FavoriteService();
diff --git a/web/frontend/src/application/services/LogService.js b/web/frontend/src/application/services/LogService.js
new file mode 100644
index 0000000..0f45ac2
--- /dev/null
+++ b/web/frontend/src/application/services/LogService.js
@@ -0,0 +1,9 @@
+import logRepository from '../../domain/repositories/LogRepository.js';
+
+class LogService {
+ async getLogs(limit = 500, sinceLine = null) {
+ return await logRepository.getLogs(limit, sinceLine);
+ }
+}
+
+export default new LogService();
diff --git a/web/frontend/src/services/pushNotifications.js b/web/frontend/src/application/services/PushNotificationService.js
similarity index 77%
rename from web/frontend/src/services/pushNotifications.js
rename to web/frontend/src/application/services/PushNotificationService.js
index 9313f19..6b4bd35 100644
--- a/web/frontend/src/services/pushNotifications.js
+++ b/web/frontend/src/application/services/PushNotificationService.js
@@ -1,11 +1,10 @@
-// Servicio para manejar notificaciones push
+// Push Notification Service
class PushNotificationService {
constructor() {
this.registration = null;
this.subscription = null;
}
- // Registrar Service Worker
async registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
@@ -13,22 +12,17 @@ class PushNotificationService {
scope: '/'
});
this.registration = registration;
- console.log('Service Worker registrado:', registration.scope);
return registration;
} catch (error) {
console.error('Error registrando Service Worker:', error);
return null;
}
- } else {
- console.warn('Service Workers no están soportados en este navegador');
- return null;
}
+ return null;
}
- // Solicitar permisos de notificación
async requestPermission() {
if (!('Notification' in window)) {
- console.warn('Este navegador no soporta notificaciones');
return 'denied';
}
@@ -41,15 +35,13 @@ class PushNotificationService {
}
try {
- const permission = await Notification.requestPermission();
- return permission;
+ return await Notification.requestPermission();
} catch (error) {
console.error('Error solicitando permiso:', error);
return 'denied';
}
}
- // Suscribirse a notificaciones push
async subscribe() {
if (!this.registration) {
await this.registerServiceWorker();
@@ -60,30 +52,22 @@ class PushNotificationService {
}
try {
- // Verificar si ya existe una suscripción
this.subscription = await this.registration.pushManager.getSubscription();
if (this.subscription) {
- console.log('Ya existe una suscripción push');
return this.subscription;
}
- // Obtener la clave pública del servidor
const response = await fetch('/api/push/public-key');
const { publicKey } = await response.json();
- // Convertir la clave pública a formato ArrayBuffer
const applicationServerKey = this.urlBase64ToUint8Array(publicKey);
- // Crear nueva suscripción
this.subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
- console.log('Suscripción push creada:', this.subscription);
-
- // Enviar la suscripción al servidor
await this.sendSubscriptionToServer(this.subscription);
return this.subscription;
@@ -93,7 +77,6 @@ class PushNotificationService {
}
}
- // Enviar suscripción al servidor
async sendSubscriptionToServer(subscription) {
try {
const response = await fetch('/api/push/subscribe', {
@@ -108,7 +91,6 @@ class PushNotificationService {
throw new Error('Error enviando suscripción al servidor');
}
- console.log('Suscripción enviada al servidor');
return await response.json();
} catch (error) {
console.error('Error enviando suscripción:', error);
@@ -116,7 +98,6 @@ class PushNotificationService {
}
}
- // Cancelar suscripción
async unsubscribe() {
if (this.subscription) {
try {
@@ -129,7 +110,6 @@ class PushNotificationService {
body: JSON.stringify(this.subscription),
});
this.subscription = null;
- console.log('Suscripción cancelada');
return true;
} catch (error) {
console.error('Error cancelando suscripción:', error);
@@ -139,7 +119,6 @@ class PushNotificationService {
return false;
}
- // Verificar estado de suscripción
async checkSubscription() {
if (!this.registration) {
await this.registerServiceWorker();
@@ -158,7 +137,6 @@ class PushNotificationService {
}
}
- // Convertir clave pública de base64 URL a Uint8Array
urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
@@ -174,7 +152,6 @@ class PushNotificationService {
return outputArray;
}
- // Inicializar todo el proceso
async init() {
const permission = await this.requestPermission();
if (permission === 'granted') {
@@ -191,5 +168,4 @@ class PushNotificationService {
}
}
-// Exportar instancia singleton
export default new PushNotificationService();
diff --git a/web/frontend/src/application/services/StatsService.js b/web/frontend/src/application/services/StatsService.js
new file mode 100644
index 0000000..2135af6
--- /dev/null
+++ b/web/frontend/src/application/services/StatsService.js
@@ -0,0 +1,9 @@
+import statsRepository from '../../domain/repositories/StatsRepository.js';
+
+class StatsService {
+ async getStats() {
+ return await statsRepository.getStats();
+ }
+}
+
+export default new StatsService();
diff --git a/web/frontend/src/application/services/TelegramService.js b/web/frontend/src/application/services/TelegramService.js
new file mode 100644
index 0000000..bfa1d99
--- /dev/null
+++ b/web/frontend/src/application/services/TelegramService.js
@@ -0,0 +1,17 @@
+import telegramRepository from '../../domain/repositories/TelegramRepository.js';
+
+class TelegramService {
+ async getConfig() {
+ return await telegramRepository.getConfig();
+ }
+
+ async setConfig(config) {
+ return await telegramRepository.setConfig(config);
+ }
+
+ async getThreads() {
+ return await telegramRepository.getThreads();
+ }
+}
+
+export default new TelegramService();
diff --git a/web/frontend/src/application/services/UserService.js b/web/frontend/src/application/services/UserService.js
new file mode 100644
index 0000000..75b5267
--- /dev/null
+++ b/web/frontend/src/application/services/UserService.js
@@ -0,0 +1,21 @@
+import userRepository from '../../domain/repositories/UserRepository.js';
+
+class UserService {
+ async getUsers() {
+ return await userRepository.getUsers();
+ }
+
+ async createUser(userData) {
+ return await userRepository.createUser(userData);
+ }
+
+ async deleteUser(username) {
+ await userRepository.deleteUser(username);
+ }
+
+ async changePassword(passwordData) {
+ return await userRepository.changePassword(passwordData);
+ }
+}
+
+export default new UserService();
diff --git a/web/frontend/src/application/services/WorkerService.js b/web/frontend/src/application/services/WorkerService.js
new file mode 100644
index 0000000..7c58124
--- /dev/null
+++ b/web/frontend/src/application/services/WorkerService.js
@@ -0,0 +1,13 @@
+import workerRepository from '../../domain/repositories/WorkerRepository.js';
+
+class WorkerService {
+ async getWorkers() {
+ return await workerRepository.getWorkers();
+ }
+
+ async updateWorkers(workers) {
+ return await workerRepository.updateWorkers(workers);
+ }
+}
+
+export default new WorkerService();
diff --git a/web/frontend/src/core/config/index.js b/web/frontend/src/core/config/index.js
new file mode 100644
index 0000000..b26c464
--- /dev/null
+++ b/web/frontend/src/core/config/index.js
@@ -0,0 +1,3 @@
+// Core configuration
+export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
+export const WS_BASE_URL = API_BASE_URL.replace(/^https?:/, window.location.protocol === 'https:' ? 'wss:' : 'ws:').replace(/^\/api$/, `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`);
diff --git a/web/frontend/src/core/http/ApiClient.js b/web/frontend/src/core/http/ApiClient.js
new file mode 100644
index 0000000..18cb0ef
--- /dev/null
+++ b/web/frontend/src/core/http/ApiClient.js
@@ -0,0 +1,75 @@
+import axios from 'axios';
+import { API_BASE_URL } from '../config/index.js';
+
+class ApiClient {
+ constructor(baseURL = API_BASE_URL) {
+ this.client = axios.create({
+ baseURL,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ this.setupInterceptors();
+ }
+
+ setupInterceptors() {
+ // Request interceptor - add auth header
+ this.client.interceptors.request.use(
+ (config) => {
+ const authHeader = this.getAuthHeader();
+ if (authHeader) {
+ config.headers.Authorization = authHeader;
+ }
+ return config;
+ },
+ (error) => Promise.reject(error)
+ );
+
+ // Response interceptor - handle 401 errors
+ this.client.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response?.status === 401) {
+ window.dispatchEvent(new CustomEvent('auth-required', {
+ detail: {
+ message: 'Se requiere autenticación para esta acción',
+ config: error.config,
+ },
+ }));
+ }
+ return Promise.reject(error);
+ }
+ );
+ }
+
+ setAuthToken(token) {
+ this.token = token;
+ }
+
+ clearAuthToken() {
+ this.token = null;
+ }
+
+ getAuthHeader() {
+ return this.token ? `Bearer ${this.token}` : null;
+ }
+
+ get(url, config = {}) {
+ return this.client.get(url, config);
+ }
+
+ post(url, data, config = {}) {
+ return this.client.post(url, data, config);
+ }
+
+ put(url, data, config = {}) {
+ return this.client.put(url, data, config);
+ }
+
+ delete(url, config = {}) {
+ return this.client.delete(url, config);
+ }
+}
+
+export default new ApiClient();
diff --git a/web/frontend/src/core/storage/LocalStorageAdapter.js b/web/frontend/src/core/storage/LocalStorageAdapter.js
new file mode 100644
index 0000000..2fad3ec
--- /dev/null
+++ b/web/frontend/src/core/storage/LocalStorageAdapter.js
@@ -0,0 +1,46 @@
+// LocalStorage adapter for storage abstraction
+export class LocalStorageAdapter {
+ constructor() {
+ this.storage = localStorage;
+ }
+
+ get(key) {
+ try {
+ const item = this.storage.getItem(key);
+ return item ? JSON.parse(item) : null;
+ } catch (error) {
+ console.error(`Error reading from localStorage key "${key}":`, error);
+ return null;
+ }
+ }
+
+ set(key, value) {
+ try {
+ this.storage.setItem(key, JSON.stringify(value));
+ return true;
+ } catch (error) {
+ console.error(`Error writing to localStorage key "${key}":`, error);
+ return false;
+ }
+ }
+
+ remove(key) {
+ try {
+ this.storage.removeItem(key);
+ return true;
+ } catch (error) {
+ console.error(`Error removing from localStorage key "${key}":`, error);
+ return false;
+ }
+ }
+
+ clear() {
+ try {
+ this.storage.clear();
+ return true;
+ } catch (error) {
+ console.error('Error clearing localStorage:', error);
+ return false;
+ }
+ }
+}
diff --git a/web/frontend/src/core/storage/StorageService.js b/web/frontend/src/core/storage/StorageService.js
new file mode 100644
index 0000000..a1cc8c5
--- /dev/null
+++ b/web/frontend/src/core/storage/StorageService.js
@@ -0,0 +1,25 @@
+import { LocalStorageAdapter } from './LocalStorageAdapter.js';
+
+class StorageService {
+ constructor(adapter) {
+ this.adapter = adapter || new LocalStorageAdapter();
+ }
+
+ get(key) {
+ return this.adapter.get(key);
+ }
+
+ set(key, value) {
+ return this.adapter.set(key, value);
+ }
+
+ remove(key) {
+ return this.adapter.remove(key);
+ }
+
+ clear() {
+ return this.adapter.clear();
+ }
+}
+
+export default new StorageService();
diff --git a/web/frontend/src/core/websocket/WebSocketService.js b/web/frontend/src/core/websocket/WebSocketService.js
new file mode 100644
index 0000000..e896c53
--- /dev/null
+++ b/web/frontend/src/core/websocket/WebSocketService.js
@@ -0,0 +1,146 @@
+import { WS_BASE_URL } from '../config/index.js';
+
+class WebSocketService {
+ constructor() {
+ this.ws = null;
+ this.reconnectAttempts = 0;
+ this.maxReconnectAttempts = 5;
+ this.reconnectDelay = 3000;
+ this.listeners = new Set();
+ }
+
+ connect(token) {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ return;
+ }
+
+ this.setToken(token);
+ this.close();
+
+ try {
+ const wsUrl = this.buildWebSocketUrl(token);
+ this.ws = new WebSocket(wsUrl);
+
+ this.ws.onopen = () => {
+ this.reconnectAttempts = 0;
+ this.onOpen();
+ };
+
+ this.ws.onclose = (event) => {
+ this.onClose(event);
+ };
+
+ this.ws.onerror = (error) => {
+ this.onError(error);
+ };
+
+ this.ws.onmessage = (event) => {
+ this.onMessage(event);
+ };
+ } catch (error) {
+ console.error('Error creating WebSocket connection:', error);
+ }
+ }
+
+ buildWebSocketUrl(token) {
+ if (!token) {
+ throw new Error('Token is required for WebSocket connection');
+ }
+
+ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
+
+ if (apiBaseUrl && apiBaseUrl !== '/api') {
+ try {
+ const url = new URL(apiBaseUrl);
+ const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${protocol}//${url.host}/ws?token=${encodeURIComponent(token)}`;
+ } catch (e) {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
+ }
+ }
+
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
+ }
+
+ onOpen() {
+ console.log('WebSocket connected');
+ window.dispatchEvent(new CustomEvent('ws-connected'));
+ }
+
+ onClose(event) {
+ console.log('WebSocket disconnected', event.code);
+ window.dispatchEvent(new CustomEvent('ws-disconnected', { detail: { code: event.code } }));
+
+ if (event.code === 1008) {
+ // Authentication failed, don't reconnect
+ return;
+ }
+
+ // Attempt to reconnect
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
+ this.reconnectAttempts++;
+ setTimeout(() => {
+ if (this.token) {
+ this.connect(this.token);
+ }
+ }, this.reconnectDelay);
+ }
+ }
+
+ onError(error) {
+ console.error('WebSocket error:', error);
+ window.dispatchEvent(new CustomEvent('ws-error', { detail: error }));
+ }
+
+ onMessage(event) {
+ try {
+ const data = JSON.parse(event.data);
+ window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
+
+ // Notify all listeners
+ this.listeners.forEach(listener => {
+ try {
+ listener(data);
+ } catch (error) {
+ console.error('Error in WebSocket listener:', error);
+ }
+ });
+ } catch (error) {
+ console.error('Error parsing WebSocket message:', error);
+ }
+ }
+
+ getToken() {
+ // Token will be passed when connecting
+ return this.token || null;
+ }
+
+ setToken(token) {
+ this.token = token;
+ }
+
+ close() {
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+ this.token = null;
+ }
+
+ isConnected() {
+ return this.ws && this.ws.readyState === WebSocket.OPEN;
+ }
+
+ addListener(callback) {
+ this.listeners.add(callback);
+ return () => this.listeners.delete(callback);
+ }
+
+ removeListener(callback) {
+ this.listeners.delete(callback);
+ }
+}
+
+export default new WebSocketService();
diff --git a/web/frontend/src/domain/entities/Article.js b/web/frontend/src/domain/entities/Article.js
new file mode 100644
index 0000000..2b5f43c
--- /dev/null
+++ b/web/frontend/src/domain/entities/Article.js
@@ -0,0 +1,59 @@
+// Article entity
+export class Article {
+ constructor(data) {
+ this.id = data.id;
+ this.platform = data.platform;
+ this.title = data.title || 'Sin título';
+ this.description = data.description || '';
+ this.price = data.price;
+ this.currency = data.currency || '€';
+ this.url = data.url;
+ this.images = data.images || [];
+ this.location = data.location;
+ this.allows_shipping = data.allows_shipping;
+ this.modified_at = data.modified_at;
+ this.notifiedAt = data.notifiedAt;
+ this.addedAt = data.addedAt;
+ this.username = data.username;
+ this.worker_name = data.worker_name;
+ this.is_favorite = data.is_favorite || false;
+ }
+
+ get displayPrice() {
+ if (this.price === null || this.price === undefined) return 'N/A';
+ return `${this.price} ${this.currency}`;
+ }
+
+ get hasImages() {
+ return this.images && this.images.length > 0;
+ }
+
+ get primaryImage() {
+ return this.hasImages ? this.images[0] : null;
+ }
+
+ get uniqueId() {
+ return `${this.platform}-${this.id}`;
+ }
+
+ toJSON() {
+ return {
+ id: this.id,
+ platform: this.platform,
+ title: this.title,
+ description: this.description,
+ price: this.price,
+ currency: this.currency,
+ url: this.url,
+ images: this.images,
+ location: this.location,
+ allows_shipping: this.allows_shipping,
+ modified_at: this.modified_at,
+ notifiedAt: this.notifiedAt,
+ addedAt: this.addedAt,
+ username: this.username,
+ worker_name: this.worker_name,
+ is_favorite: this.is_favorite,
+ };
+ }
+}
diff --git a/web/frontend/src/domain/entities/Favorite.js b/web/frontend/src/domain/entities/Favorite.js
new file mode 100644
index 0000000..edee998
--- /dev/null
+++ b/web/frontend/src/domain/entities/Favorite.js
@@ -0,0 +1,19 @@
+// Favorite entity
+export class Favorite {
+ constructor({ platform, id, article = null }) {
+ this.platform = platform;
+ this.id = String(id);
+ this.article = article;
+ }
+
+ get uniqueId() {
+ return `${this.platform}-${this.id}`;
+ }
+
+ toJSON() {
+ return {
+ platform: this.platform,
+ id: this.id,
+ };
+ }
+}
diff --git a/web/frontend/src/domain/entities/User.js b/web/frontend/src/domain/entities/User.js
new file mode 100644
index 0000000..ee032fa
--- /dev/null
+++ b/web/frontend/src/domain/entities/User.js
@@ -0,0 +1,24 @@
+// User entity
+export class User {
+ constructor({ username, role = 'user', token = null }) {
+ this.username = username;
+ this.role = role;
+ this.token = token;
+ }
+
+ get isAdmin() {
+ return this.role === 'admin';
+ }
+
+ get isAuthenticated() {
+ return !!this.token;
+ }
+
+ toJSON() {
+ return {
+ username: this.username,
+ role: this.role,
+ token: this.token,
+ };
+ }
+}
diff --git a/web/frontend/src/domain/repositories/AdminRepository.js b/web/frontend/src/domain/repositories/AdminRepository.js
new file mode 100644
index 0000000..5893970
--- /dev/null
+++ b/web/frontend/src/domain/repositories/AdminRepository.js
@@ -0,0 +1,19 @@
+import apiClient from '../../core/http/ApiClient.js';
+
+class AdminRepository {
+ async getRateLimiterInfo() {
+ const response = await apiClient.get('/admin/rate-limiter');
+ return response.data;
+ }
+
+ async getSessions() {
+ const response = await apiClient.get('/admin/sessions');
+ return response.data;
+ }
+
+ async deleteSession(token) {
+ await apiClient.delete(`/admin/sessions/${token}`);
+ }
+}
+
+export default new AdminRepository();
diff --git a/web/frontend/src/domain/repositories/ArticleRepository.js b/web/frontend/src/domain/repositories/ArticleRepository.js
new file mode 100644
index 0000000..ce5f423
--- /dev/null
+++ b/web/frontend/src/domain/repositories/ArticleRepository.js
@@ -0,0 +1,35 @@
+import apiClient from '../../core/http/ApiClient.js';
+import { Article } from '../entities/Article.js';
+
+class ArticleRepository {
+ async getArticles(limit = 100, offset = 0, filters = {}) {
+ const params = { limit, offset, ...filters };
+ const response = await apiClient.get('/articles', { params });
+ return {
+ articles: response.data.articles.map(a => new Article(a)),
+ total: response.data.total,
+ };
+ }
+
+ async getArticleFacets() {
+ const response = await apiClient.get('/articles/facets');
+ return response.data;
+ }
+
+ async searchArticles(query, mode = 'AND') {
+ const response = await apiClient.get('/articles/search', {
+ params: { q: query, mode },
+ });
+ return {
+ articles: response.data.articles.map(a => new Article(a)),
+ total: response.data.total,
+ };
+ }
+
+ async getArticle(platform, id) {
+ const response = await apiClient.get(`/articles/${platform}/${id}`);
+ return new Article(response.data);
+ }
+}
+
+export default new ArticleRepository();
diff --git a/web/frontend/src/domain/repositories/AuthRepository.js b/web/frontend/src/domain/repositories/AuthRepository.js
new file mode 100644
index 0000000..10da624
--- /dev/null
+++ b/web/frontend/src/domain/repositories/AuthRepository.js
@@ -0,0 +1,76 @@
+import apiClient from '../../core/http/ApiClient.js';
+import { getDeviceFingerprint } from '../../shared/utils/fingerprint.js';
+
+class AuthRepository {
+ async login(username, password) {
+ let fingerprintData = null;
+ try {
+ fingerprintData = await getDeviceFingerprint();
+ } catch (error) {
+ console.warn('Error obteniendo fingerprint, continuando sin él:', error);
+ }
+
+ const requestBody = {
+ username,
+ password,
+ };
+
+ if (fingerprintData) {
+ requestBody.fingerprint = fingerprintData.fingerprint;
+ requestBody.deviceInfo = fingerprintData.deviceInfo;
+ }
+
+ const response = await fetch('/api/users/login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Error en login');
+ }
+
+ return data;
+ }
+
+ async logout(token) {
+ try {
+ await fetch('/api/users/logout', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ });
+ } catch (error) {
+ console.error('Error al cerrar sesión en el servidor:', error);
+ }
+ }
+
+ async validateSession(token) {
+ try {
+ const response = await fetch('/api/users/me', {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ },
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ return data.success && data.authenticated ? data : null;
+ }
+
+ return null;
+ } catch (error) {
+ console.error('Error validando sesión:', error);
+ return null;
+ }
+ }
+}
+
+export default new AuthRepository();
diff --git a/web/frontend/src/domain/repositories/FavoriteRepository.js b/web/frontend/src/domain/repositories/FavoriteRepository.js
new file mode 100644
index 0000000..7850f1b
--- /dev/null
+++ b/web/frontend/src/domain/repositories/FavoriteRepository.js
@@ -0,0 +1,20 @@
+import apiClient from '../../core/http/ApiClient.js';
+import { Favorite } from '../entities/Favorite.js';
+
+class FavoriteRepository {
+ async getFavorites() {
+ const response = await apiClient.get('/favorites');
+ return response.data.map(f => new Favorite(f));
+ }
+
+ async addFavorite(favorite) {
+ const response = await apiClient.post('/favorites', favorite.toJSON());
+ return new Favorite(response.data);
+ }
+
+ async removeFavorite(platform, id) {
+ await apiClient.delete(`/favorites/${platform}/${id}`);
+ }
+}
+
+export default new FavoriteRepository();
diff --git a/web/frontend/src/domain/repositories/LogRepository.js b/web/frontend/src/domain/repositories/LogRepository.js
new file mode 100644
index 0000000..64eafb8
--- /dev/null
+++ b/web/frontend/src/domain/repositories/LogRepository.js
@@ -0,0 +1,14 @@
+import apiClient from '../../core/http/ApiClient.js';
+
+class LogRepository {
+ async getLogs(limit = 500, sinceLine = null) {
+ const params = { limit };
+ if (sinceLine !== null && sinceLine > 0) {
+ params.since = sinceLine;
+ }
+ const response = await apiClient.get('/logs', { params });
+ return response.data;
+ }
+}
+
+export default new LogRepository();
diff --git a/web/frontend/src/domain/repositories/StatsRepository.js b/web/frontend/src/domain/repositories/StatsRepository.js
new file mode 100644
index 0000000..89ebd8b
--- /dev/null
+++ b/web/frontend/src/domain/repositories/StatsRepository.js
@@ -0,0 +1,10 @@
+import apiClient from '../../core/http/ApiClient.js';
+
+class StatsRepository {
+ async getStats() {
+ const response = await apiClient.get('/stats');
+ return response.data;
+ }
+}
+
+export default new StatsRepository();
diff --git a/web/frontend/src/domain/repositories/TelegramRepository.js b/web/frontend/src/domain/repositories/TelegramRepository.js
new file mode 100644
index 0000000..14d7618
--- /dev/null
+++ b/web/frontend/src/domain/repositories/TelegramRepository.js
@@ -0,0 +1,20 @@
+import apiClient from '../../core/http/ApiClient.js';
+
+class TelegramRepository {
+ async getConfig() {
+ const response = await apiClient.get('/telegram/config');
+ return response.data;
+ }
+
+ async setConfig(config) {
+ const response = await apiClient.put('/telegram/config', config);
+ return response.data;
+ }
+
+ async getThreads() {
+ const response = await apiClient.get('/telegram/threads');
+ return response.data;
+ }
+}
+
+export default new TelegramRepository();
diff --git a/web/frontend/src/domain/repositories/UserRepository.js b/web/frontend/src/domain/repositories/UserRepository.js
new file mode 100644
index 0000000..4b487ec
--- /dev/null
+++ b/web/frontend/src/domain/repositories/UserRepository.js
@@ -0,0 +1,24 @@
+import apiClient from '../../core/http/ApiClient.js';
+
+class UserRepository {
+ async getUsers() {
+ const response = await apiClient.get('/users');
+ return response.data;
+ }
+
+ async createUser(userData) {
+ const response = await apiClient.post('/users', userData);
+ return response.data;
+ }
+
+ async deleteUser(username) {
+ await apiClient.delete(`/users/${username}`);
+ }
+
+ async changePassword(passwordData) {
+ const response = await apiClient.post('/users/change-password', passwordData);
+ return response.data;
+ }
+}
+
+export default new UserRepository();
diff --git a/web/frontend/src/domain/repositories/WorkerRepository.js b/web/frontend/src/domain/repositories/WorkerRepository.js
new file mode 100644
index 0000000..dba6d81
--- /dev/null
+++ b/web/frontend/src/domain/repositories/WorkerRepository.js
@@ -0,0 +1,15 @@
+import apiClient from '../../core/http/ApiClient.js';
+
+class WorkerRepository {
+ async getWorkers() {
+ const response = await apiClient.get('/workers');
+ return response.data;
+ }
+
+ async updateWorkers(workers) {
+ const response = await apiClient.put('/workers', workers);
+ return response.data;
+ }
+}
+
+export default new WorkerRepository();
diff --git a/web/frontend/src/main.js b/web/frontend/src/main.js
index 66afc98..b9c4476 100644
--- a/web/frontend/src/main.js
+++ b/web/frontend/src/main.js
@@ -1,79 +1,23 @@
import { createApp } from 'vue';
-import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
-import Dashboard from './views/Dashboard.vue';
-import Articles from './views/Articles.vue';
-import ArticleDetail from './views/ArticleDetail.vue';
-import Favorites from './views/Favorites.vue';
-import Workers from './views/Workers.vue';
-import Users from './views/Users.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 router from './presentation/router/index.js';
+import authService from './application/services/AuthService.js';
+import webSocketService from './core/websocket/WebSocketService.js';
import './style.css';
-import authService from './services/auth';
-
-const routes = [
- { path: '/login', component: Login, name: 'login' },
- { path: '/', component: Dashboard, 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: '/workers', component: Workers, meta: { requiresAuth: true } },
- { path: '/users', component: Users, 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({
- history: createWebHistory(),
- routes,
-});
-
-// Guard de navegación para verificar autenticación
-router.beforeEach(async (to, from, next) => {
- // Si la ruta es /login y ya está autenticado, redirigir al dashboard
- if (to.path === '/login') {
- if (authService.hasCredentials()) {
- const isValid = await authService.validateSession();
- if (isValid) {
- next('/');
- return;
- }
- }
- next();
- return;
- }
-
- // Para todas las demás rutas, verificar autenticación
- if (to.meta.requiresAuth) {
- // Verificar si hay token almacenado
- if (!authService.hasCredentials()) {
- // No hay token, redirigir a login
- next('/login');
- return;
- }
-
- // Hay token, validar si sigue siendo válido
- const isValid = await authService.validateSession();
- if (!isValid) {
- // Token inválido o expirado, redirigir a login
- next('/login');
- return;
- }
- }
-
- // Continuar la navegación
- next();
-});
const app = createApp(App);
app.use(router);
app.mount('#app');
-// Registrar Service Worker automáticamente al cargar la app
+// Initialize WebSocket connection if authenticated
+if (authService.hasCredentials()) {
+ const token = authService.getToken();
+ if (token) {
+ webSocketService.connect(token);
+ }
+}
+
+// Register Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
@@ -86,4 +30,3 @@ if ('serviceWorker' in navigator) {
}
});
}
-
diff --git a/web/frontend/src/components/ArticleCard.vue b/web/frontend/src/presentation/components/ArticleCard.vue
similarity index 95%
rename from web/frontend/src/components/ArticleCard.vue
rename to web/frontend/src/presentation/components/ArticleCard.vue
index 6d5f552..04cce62 100644
--- a/web/frontend/src/components/ArticleCard.vue
+++ b/web/frontend/src/presentation/components/ArticleCard.vue
@@ -145,8 +145,9 @@ import { defineProps, defineEmits, ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { HeartIcon } from '@heroicons/vue/24/outline';
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
-import authService from '../services/auth';
-import api from '../services/api';
+import authService from '../../application/services/AuthService.js';
+import favoriteService from '../../application/services/FavoriteService.js';
+import { formatDate } from '../../shared/utils/date.js';
const router = useRouter();
@@ -181,10 +182,6 @@ function checkAuth() {
isAuthenticated.value = authService.hasCredentials();
}
-function formatDate(timestamp) {
- if (!timestamp) return 'N/A';
- return new Date(timestamp).toLocaleString('es-ES');
-}
function handleImageError(event) {
// Si la imagen falla al cargar, reemplazar con placeholder
@@ -205,20 +202,11 @@ async function handleAddFavorite() {
isAdding.value = true;
try {
- // El backend solo necesita platform e id
- const favorite = {
- platform: props.article.platform,
- id: String(props.article.id), // Asegurar que sea string
- };
-
- await api.addFavorite(favorite);
+ await favoriteService.addFavorite(props.article.platform, props.article.id);
favoriteStatus.value = true;
-
- // Emitir evento para que el componente padre pueda actualizar si es necesario
emit('added', props.article.platform, props.article.id);
} catch (error) {
console.error('Error añadiendo a favoritos:', error);
- // El interceptor de API ya maneja el error 401 mostrando el modal de login
if (error.response?.status === 404) {
alert('El artículo no se encontró en la base de datos. Asegúrate de que el artículo esté en la lista de notificados.');
} else if (error.response?.status === 400) {
diff --git a/web/frontend/src/components/ToastContainer.vue b/web/frontend/src/presentation/components/ToastContainer.vue
similarity index 88%
rename from web/frontend/src/components/ToastContainer.vue
rename to web/frontend/src/presentation/components/ToastContainer.vue
index d4c77b7..fdc92c3 100644
--- a/web/frontend/src/components/ToastContainer.vue
+++ b/web/frontend/src/presentation/components/ToastContainer.vue
@@ -29,6 +29,7 @@
diff --git a/web/frontend/src/views/Favorites.vue b/web/frontend/src/presentation/views/Favorites.vue
similarity index 73%
rename from web/frontend/src/views/Favorites.vue
rename to web/frontend/src/presentation/views/Favorites.vue
index 05a487e..fc010a0 100644
--- a/web/frontend/src/views/Favorites.vue
+++ b/web/frontend/src/presentation/views/Favorites.vue
@@ -48,15 +48,16 @@
diff --git a/web/frontend/src/views/Login.vue b/web/frontend/src/presentation/views/Login.vue
similarity index 99%
rename from web/frontend/src/views/Login.vue
rename to web/frontend/src/presentation/views/Login.vue
index 8bfd3c2..880f8db 100644
--- a/web/frontend/src/views/Login.vue
+++ b/web/frontend/src/presentation/views/Login.vue
@@ -230,7 +230,7 @@
diff --git a/web/frontend/src/services/api.js b/web/frontend/src/services/api.js
deleted file mode 100644
index 233ed81..0000000
--- a/web/frontend/src/services/api.js
+++ /dev/null
@@ -1,175 +0,0 @@
-import axios from 'axios';
-import authService from './auth';
-
-// Usar variable de entorno si está disponible, sino usar '/api' (proxy en desarrollo)
-const baseURL = import.meta.env.VITE_API_BASE_URL || '/api';
-
-console.log('baseURL', baseURL);
-
-const api = axios.create({
- baseURL,
- headers: {
- 'Content-Type': 'application/json',
- },
-});
-
-// Interceptor para añadir autenticación a las peticiones
-api.interceptors.request.use(
- (config) => {
- const authHeader = authService.getAuthHeader();
- if (authHeader) {
- config.headers.Authorization = authHeader;
- }
- return config;
- },
- (error) => {
- return Promise.reject(error);
- }
-);
-
-// Interceptor para manejar errores 401 (no autenticado)
-api.interceptors.response.use(
- (response) => response,
- (error) => {
- if (error.response?.status === 401) {
- // Disparar evento personalizado para mostrar diálogo de login
- window.dispatchEvent(new CustomEvent('auth-required', {
- detail: {
- message: 'Se requiere autenticación para esta acción',
- config: error.config
- }
- }));
- }
- return Promise.reject(error);
- }
-);
-
-export default {
- // Estadísticas
- async getStats() {
- const response = await api.get('/stats');
- return response.data;
- },
-
- // Workers
- async getWorkers() {
- const response = await api.get('/workers');
- return response.data;
- },
-
- async updateWorkers(workers) {
- const response = await api.put('/workers', workers);
- return response.data;
- },
-
- // Favoritos
- async getFavorites() {
- const response = await api.get('/favorites');
- return response.data;
- },
-
- async addFavorite(favorite) {
- const response = await api.post('/favorites', favorite);
- return response.data;
- },
-
- async removeFavorite(platform, id) {
- const response = await api.delete(`/favorites/${platform}/${id}`);
- return response.data;
- },
-
- // Artículos
- async getArticles(limit = 100, offset = 0, additionalParams = {}) {
- const params = { limit, offset, ...additionalParams };
- const response = await api.get('/articles', { params });
- return response.data;
- },
-
- async getArticleFacets() {
- const response = await api.get('/articles/facets');
- return response.data;
- },
-
- async searchArticles(query, mode = 'AND') {
- const response = await api.get('/articles/search', {
- params: { q: query, mode: mode },
- });
- return response.data;
- },
-
- async getArticle(platform, id) {
- const response = await api.get(`/articles/${platform}/${id}`);
- return response.data;
- },
-
- // Logs
- async getLogs(limit = 500, sinceLine = null) {
- const params = { limit };
- if (sinceLine !== null && sinceLine > 0) {
- params.since = sinceLine;
- }
- const response = await api.get('/logs', { params });
- return response.data;
- },
-
- // Configuración
- async getConfig() {
- const response = await api.get('/config');
- return response.data;
- },
-
- // Telegram
- async getTelegramConfig() {
- const response = await api.get('/telegram/config');
- return response.data;
- },
-
- async setTelegramConfig(config) {
- const response = await api.put('/telegram/config', config);
- return response.data;
- },
-
- async getTelegramThreads() {
- const response = await api.get('/telegram/threads');
- return response.data;
- },
-
- // Usuarios
- async getUsers() {
- const response = await api.get('/users');
- return response.data;
- },
-
- async createUser(userData) {
- const response = await api.post('/users', userData);
- return response.data;
- },
-
- async deleteUser(username) {
- const response = await api.delete(`/users/${username}`);
- return response.data;
- },
-
- async changePassword(passwordData) {
- const response = await api.post('/users/change-password', passwordData);
- 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;
- },
-};
-
diff --git a/web/frontend/src/services/auth.js b/web/frontend/src/services/auth.js
deleted file mode 100644
index f916ba1..0000000
--- a/web/frontend/src/services/auth.js
+++ /dev/null
@@ -1,230 +0,0 @@
-// Servicio de autenticación para gestionar tokens
-import { getDeviceFingerprint } from './fingerprint.js';
-
-const AUTH_STORAGE_KEY = 'wallabicher_token';
-const USERNAME_STORAGE_KEY = 'wallabicher_username';
-const ROLE_STORAGE_KEY = 'wallabicher_role';
-
-class AuthService {
- constructor() {
- this.token = this.loadToken();
- this.username = this.loadUsername();
- this.role = this.loadRole();
- }
-
- // Cargar token desde localStorage
- loadToken() {
- try {
- return localStorage.getItem(AUTH_STORAGE_KEY) || '';
- } catch (error) {
- console.error('Error cargando token:', error);
- return '';
- }
- }
-
- // Cargar username desde localStorage
- loadUsername() {
- try {
- return localStorage.getItem(USERNAME_STORAGE_KEY) || '';
- } catch (error) {
- console.error('Error cargando username:', error);
- return '';
- }
- }
-
- // Guardar token, username y role en localStorage
- saveSession(token, username, role = 'user') {
- try {
- this.token = token;
- this.username = username;
- this.role = role;
- localStorage.setItem(AUTH_STORAGE_KEY, token);
- localStorage.setItem(USERNAME_STORAGE_KEY, username);
- localStorage.setItem(ROLE_STORAGE_KEY, role);
- return true;
- } catch (error) {
- console.error('Error guardando sesión:', error);
- return false;
- }
- }
-
- // Cargar role desde localStorage
- loadRole() {
- try {
- return localStorage.getItem(ROLE_STORAGE_KEY) || 'user';
- } catch (error) {
- console.error('Error cargando role:', error);
- return 'user';
- }
- }
-
- // Eliminar token, username y role
- clearSession() {
- try {
- this.token = '';
- this.username = '';
- this.role = 'user';
- localStorage.removeItem(AUTH_STORAGE_KEY);
- localStorage.removeItem(USERNAME_STORAGE_KEY);
- localStorage.removeItem(ROLE_STORAGE_KEY);
- return true;
- } catch (error) {
- console.error('Error eliminando sesión:', error);
- return false;
- }
- }
-
- // Obtener token actual
- getToken() {
- return this.token;
- }
-
- // Obtener username actual
- getUsername() {
- return this.username;
- }
-
- // Obtener role actual
- getRole() {
- return this.role;
- }
-
- // Verificar si es admin
- isAdmin() {
- return this.role === 'admin';
- }
-
- // Verificar si hay sesión activa (token guardado)
- hasCredentials() {
- return !!this.token;
- }
-
- // Generar header de autenticación Bearer
- getAuthHeader() {
- if (!this.token) {
- return null;
- }
- return `Bearer ${this.token}`;
- }
-
- // Hacer login (llamar al endpoint de login)
- async login(username, password) {
- 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', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(requestBody),
- });
-
- const data = await response.json();
-
- if (!response.ok) {
- throw new Error(data.error || 'Error en login');
- }
-
- if (data.success && data.token) {
- const role = data.role || 'user';
- this.saveSession(data.token, data.username, role);
- return { success: true, token: data.token, username: data.username, role };
- }
-
- throw new Error('Respuesta inválida del servidor');
- } catch (error) {
- console.error('Error en login:', error);
- throw error;
- }
- }
-
- // Hacer logout (llamar al endpoint de logout)
- async logout() {
- try {
- const token = this.token;
-
- if (token) {
- try {
- await fetch('/api/users/logout', {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${token}`,
- 'Content-Type': 'application/json',
- },
- });
- } catch (error) {
- // Si falla el logout en el servidor, aún así limpiar localmente
- console.error('Error al cerrar sesión en el servidor:', error);
- }
- }
-
- this.clearSession();
- return true;
- } catch (error) {
- console.error('Error en logout:', error);
- this.clearSession(); // Limpiar localmente de todas formas
- return false;
- }
- }
-
- // Verificar si el token sigue siendo válido
- async validateSession() {
- if (!this.token) {
- return false;
- }
-
- try {
- const response = await fetch('/api/users/me', {
- method: 'GET',
- headers: {
- 'Authorization': `Bearer ${this.token}`,
- },
- });
-
- if (response.ok) {
- const data = await response.json();
- if (data.success && data.authenticated) {
- // Actualizar role si está disponible
- if (data.role) {
- this.role = data.role;
- localStorage.setItem(ROLE_STORAGE_KEY, data.role);
- }
- return true;
- }
- }
-
- // Si el token es inválido, limpiar sesión
- if (response.status === 401) {
- this.clearSession();
- }
-
- return false;
- } catch (error) {
- console.error('Error validando sesión:', error);
- return false;
- }
- }
-}
-
-// Exportar instancia singleton
-const authService = new AuthService();
-export default authService;
-
diff --git a/web/frontend/src/shared/utils/date.js b/web/frontend/src/shared/utils/date.js
new file mode 100644
index 0000000..76b5869
--- /dev/null
+++ b/web/frontend/src/shared/utils/date.js
@@ -0,0 +1,15 @@
+// Date utility functions
+export function formatDate(timestamp) {
+ if (!timestamp) return 'N/A';
+ return new Date(timestamp).toLocaleString('es-ES');
+}
+
+export function formatDateShort(timestamp) {
+ if (!timestamp) return 'N/A';
+ return new Date(timestamp).toLocaleDateString('es-ES');
+}
+
+export function formatTime(timestamp) {
+ if (!timestamp) return 'N/A';
+ return new Date(timestamp).toLocaleTimeString('es-ES');
+}
diff --git a/web/frontend/src/services/fingerprint.js b/web/frontend/src/shared/utils/fingerprint.js
similarity index 76%
rename from web/frontend/src/services/fingerprint.js
rename to web/frontend/src/shared/utils/fingerprint.js
index b4da9e8..43942f1 100644
--- a/web/frontend/src/services/fingerprint.js
+++ b/web/frontend/src/shared/utils/fingerprint.js
@@ -4,9 +4,6 @@ let fpPromise = null;
let cachedFingerprint = null;
let cachedDeviceInfo = null;
-/**
- * Inicializa FingerprintJS (solo una vez)
- */
function initFingerprintJS() {
if (!fpPromise) {
fpPromise = FingerprintJS.load();
@@ -14,12 +11,7 @@ function initFingerprintJS() {
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,
@@ -31,7 +23,6 @@ export async function getDeviceFingerprint() {
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;
@@ -43,7 +34,6 @@ export async function getDeviceFingerprint() {
};
} catch (error) {
console.error('Error obteniendo fingerprint:', error);
- // Fallback: generar un fingerprint básico
return {
fingerprint: generateFallbackFingerprint(),
deviceInfo: {
@@ -57,11 +47,6 @@ export async function getDeviceFingerprint() {
}
}
-/**
- * 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',
@@ -74,7 +59,6 @@ function extractDeviceInfo(components) {
language: navigator.language || '',
};
- // Información del navegador
if (components.browserName) {
info.browser = components.browserName.value || 'Unknown';
}
@@ -82,7 +66,6 @@ function extractDeviceInfo(components) {
info.browserVersion = components.browserVersion.value || '';
}
- // Información del sistema operativo
if (components.os) {
info.os = components.os.value || 'Unknown';
}
@@ -90,7 +73,6 @@ function extractDeviceInfo(components) {
info.osVersion = components.osVersion.value || '';
}
- // Información del dispositivo
if (components.deviceMemory) {
info.device = components.deviceMemory.value ? 'Desktop' : 'Mobile';
}
@@ -105,7 +87,6 @@ function extractDeviceInfo(components) {
}
}
- // Resolución de pantalla
if (components.screenResolution) {
const res = components.screenResolution.value;
if (res && res.length >= 2) {
@@ -113,7 +94,6 @@ function extractDeviceInfo(components) {
}
}
- // Zona horaria
if (components.timezone) {
info.timezone = components.timezone.value || '';
}
@@ -121,10 +101,6 @@ function extractDeviceInfo(components) {
return info;
}
-/**
- * Genera un fingerprint básico como fallback
- * @returns {string} Hash del fingerprint
- */
function generateFallbackFingerprint() {
const data = [
navigator.userAgent,
@@ -134,21 +110,16 @@ function generateFallbackFingerprint() {
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
+ hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
-/**
- * Limpia el caché del fingerprint (útil para testing)
- */
export function clearFingerprintCache() {
cachedFingerprint = null;
cachedDeviceInfo = null;
}
-