diff --git a/desktop/src/api/rooms/rooms.service.ts b/desktop/src/api/rooms/rooms.service.ts index 4d6cf95..95139b3 100644 --- a/desktop/src/api/rooms/rooms.service.ts +++ b/desktop/src/api/rooms/rooms.service.ts @@ -1,6 +1,11 @@ import crypto from 'crypto'; import { closeTunnelIfUnused } from "./tunnel.manager"; +interface RoomPermissions { + canControl: boolean; + canManageQueue: boolean; +} + interface RoomUser { id: string; username: string; @@ -8,6 +13,8 @@ interface RoomUser { isHost: boolean; isGuest: boolean; userId?: number; + permissions?: RoomPermissions; + ipAddress?: string; } interface SourceContext { @@ -55,8 +62,14 @@ interface RoomData { exposed: boolean; publicUrl?: string; queue: QueueItem[]; + bannedIPs: Set; } +export const DEFAULT_GUEST_PERMISSIONS: RoomPermissions = { + canControl: false, + canManageQueue: false +}; + const rooms = new Map(); export function generateRoomId(): string { @@ -77,13 +90,81 @@ export function createRoom(name: string, host: RoomUser, password?: string, expo metadata: null, exposed, publicUrl, - queue: [] + queue: [], + bannedIPs: new Set() // NUEVO }; rooms.set(roomId, room); return room; } +export function updateUserPermissions(roomId: string, userId: string, permissions: Partial): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + const user: any = room.users.get(userId); + if (!user) return false; + + if (user.isHost) return false; + + user.permissions = { + ...user.permissions, + ...permissions + }; + + return true; +} + +export function banUserIP(roomId: string, ipAddress: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + room.bannedIPs.add(ipAddress); + + // Remover a todos los usuarios con esa IP + Array.from(room.users.values()).forEach(user => { + if (user.ipAddress === ipAddress) { + removeUserFromRoom(roomId, user.id); + } + }); + + return true; +} + +export function unbanUserIP(roomId: string, ipAddress: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + return room.bannedIPs.delete(ipAddress); +} + +export function isIPBanned(roomId: string, ipAddress: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + return room.bannedIPs.has(ipAddress); +} + +export function getBannedIPs(roomId: string): string[] { + const room = rooms.get(roomId); + if (!room) return []; + return Array.from(room.bannedIPs); +} + +export function hasPermission(roomId: string, userId: string, permission: keyof RoomPermissions): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + const user = room.users.get(userId); + if (!user) return false; + + // El host siempre tiene todos los permisos + if (user.isHost) return true; + + // Si no tiene permisos definidos, usar defaults + const userPerms = user.permissions || DEFAULT_GUEST_PERMISSIONS; + return userPerms[permission] || false; +} + export function getRoom(roomId: string): RoomData | null { return rooms.get(roomId) || null; } diff --git a/desktop/src/api/rooms/rooms.websocket.ts b/desktop/src/api/rooms/rooms.websocket.ts index f50bb63..2b1a699 100644 --- a/desktop/src/api/rooms/rooms.websocket.ts +++ b/desktop/src/api/rooms/rooms.websocket.ts @@ -13,14 +13,13 @@ interface WSClient { const clients = new Map(); -interface WSParams { - roomId: string; -} -interface WSQuery { - token?: string; - guestName?: string; - password?: string; +function getClientIP(req: any): string { + return req.headers['x-forwarded-for']?.split(',')[0].trim() || + req.headers['x-real-ip'] || + req.connection?.remoteAddress || + req.socket?.remoteAddress || + 'unknown'; } export function setupRoomWebSocket(fastify: FastifyInstance) { @@ -44,13 +43,14 @@ async function handleWebSocketConnection(connection: any, req: any) { const guestName = req.query.guestName; const password = req.query.password; + const clientIP = getClientIP(req); // NUEVO + let userId: string; let username: string; let avatar: string | undefined; let isGuest = false; let realUserId: any; - // Verificar si la sala existe const room = roomService.getRoom(roomId); if (!room) { socket.send(JSON.stringify({ @@ -61,6 +61,24 @@ async function handleWebSocketConnection(connection: any, req: any) { return; } + if (roomService.isIPBanned(roomId, clientIP)) { + socket.send(JSON.stringify({ + type: 'error', + message: 'You have been banned from this room' + })); + socket.close(); + return; + } + + if (!room) { + socket.send(JSON.stringify({ + type: 'error', + message: 'Room not found' + })); + socket.close(); + return; + } + // Verificar contraseña si existe if (room.password) { if (!password || !roomService.verifyRoomPassword(roomId, password)) { @@ -134,14 +152,15 @@ async function handleWebSocketConnection(connection: any, req: any) { isHost }); - // Agregar usuario a la sala const userInRoom = { id: userId, username, avatar, - isHost: isHost, // ← CORREGIDO: Usar la verificación correcta + isHost: isHost, isGuest, - userId: realUserId + userId: realUserId, + ipAddress: clientIP, // NUEVO + permissions: isHost ? undefined : { ...roomService.DEFAULT_GUEST_PERMISSIONS } }; roomService.addUserToRoom(roomId, userInRoom); @@ -160,6 +179,7 @@ async function handleWebSocketConnection(connection: any, req: any) { userId, username, isGuest, + isHost, // NUEVO: Enviar explícitamente room: { id: room.id, name: room.name, @@ -168,7 +188,8 @@ async function handleWebSocketConnection(connection: any, req: any) { username: u.username, avatar: u.avatar, isHost: u.isHost, - isGuest: u.isGuest + isGuest: u.isGuest, + permissions: u.permissions // NUEVO })), currentVideo: room.currentVideo, queue: room.queue || [] @@ -212,6 +233,9 @@ function handleMessage(roomId: string, userId: string, data: any) { const room = roomService.getRoom(roomId); if (!room) return; + const user = room.users.get(userId); + if (!user) return; + console.log('Handling message:', data.type, 'from user:', userId, 'isHost:', room.host.id === userId); switch (data.type) { @@ -226,8 +250,65 @@ function handleMessage(roomId: string, userId: string, data: any) { }); break; + case 'update_permissions': + if (!user.isHost) { + console.warn('Non-host attempted to update permissions'); + return; + } + + const success = roomService.updateUserPermissions( + roomId, + data.targetUserId, + data.permissions + ); + + if (success) { + const updatedRoom = roomService.getRoom(roomId); + if (updatedRoom) { + broadcastToRoom(roomId, { + type: 'permissions_updated', + users: Array.from(updatedRoom.users.values()).map(u => ({ + id: u.id, + username: u.username, + avatar: u.avatar, + isHost: u.isHost, + isGuest: u.isGuest, + permissions: u.permissions + })) + }); + } + } + break; + + // NUEVO: Baneo de usuarios (solo host) + case 'ban_user': + if (!user.isHost) { + console.warn('Non-host attempted to ban user'); + return; + } + + const targetUser = room.users.get(data.targetUserId); + if (targetUser && targetUser.ipAddress) { + roomService.banUserIP(roomId, targetUser.ipAddress); + + // Cerrar conexión del usuario baneado + const targetClient = clients.get(data.targetUserId); + if (targetClient && targetClient.socket) { + targetClient.socket.send(JSON.stringify({ + type: 'banned', + message: 'You have been banned from this room' + })); + targetClient.socket.close(); + } + } + break; + + case 'queue_play_item': - if (room.host.id !== userId) return; + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) { + console.warn('User lacks permission for queue management'); + return; + } const itemToPlay = roomService.getAndRemoveQueueItem(roomId, data.itemUid); if (itemToPlay) { @@ -255,7 +336,9 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'queue_move': - if (room.host.id !== userId) return; + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) { + return; + } const moved = roomService.moveQueueItem(roomId, data.itemUid, data.direction); if (moved) { @@ -295,7 +378,14 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'video_update': - if (room.host.id !== userId) return; + const canUpdateVideo = user.isHost || + roomService.hasPermission(roomId, userId, 'canControl') || + roomService.hasPermission(roomId, userId, 'canManageQueue'); + + if (!canUpdateVideo) { + console.warn('User lacks permissions to update video'); + return; + } roomService.updateRoomVideo(roomId, data.video); roomService.updateRoomMetadata(roomId, data.metadata); @@ -308,13 +398,13 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'queue_add_batch': - if (room.host.id !== userId) return; + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) { + return; + } if (Array.isArray(data.items)) { - // Añadimos el índice (i) al forEach data.items.forEach((item: any, i: number) => { const newItem = { - // Añadimos el índice '_${i}' al UID para garantizar unicidad en milisegundos uid: `q_${Date.now()}_${i}_${Math.random().toString(36).substr(2, 5)}`, metadata: item.metadata, videoData: item.video, @@ -365,13 +455,11 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'play': - // Solo el host puede controlar la reproducción - if (room.host.id !== userId) { - console.warn('Non-host attempted play:', userId); + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) { + console.warn('User lacks control permissions'); return; } - console.log('Broadcasting play event to room:', roomId); broadcastToRoom(roomId, { type: 'play', currentTime: data.currentTime, @@ -380,8 +468,8 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'pause': - if (room.host.id !== userId) { - console.warn('Non-host attempted pause:', userId); + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) { + console.warn('User lacks control permissions for pause'); return; } @@ -394,8 +482,8 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'seek': - if (room.host.id !== userId) { - console.warn('Non-host attempted seek:', userId); + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) { + console.warn('User lacks control permissions for seek'); return; } @@ -436,19 +524,19 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'queue_add': - // Solo el host (o todos si quisieras) pueden añadir - if (room.host.id !== userId) return; + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) { + return; + } const newItem = { uid: `q_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`, metadata: data.metadata, - videoData: data.video, // El objeto que contiene url, type, subtitles + videoData: data.video, addedBy: room.users.get(userId)?.username || 'Unknown' }; roomService.addQueueItem(roomId, newItem); - // Avisar a todos que la cola cambió broadcastToRoom(roomId, { type: 'queue_update', queue: room.queue @@ -456,7 +544,10 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'queue_remove': - if (room.host.id !== userId) return; + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) { + return; + } + roomService.removeQueueItem(roomId, data.itemUid); broadcastToRoom(roomId, { type: 'queue_update', diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js index a694675..2c1a133 100644 --- a/desktop/src/scripts/anime/player.js +++ b/desktop/src/scripts/anime/player.js @@ -402,8 +402,8 @@ const AnimePlayer = (function() { // Control functions function togglePlayPause() { - if (_roomMode && !_isRoomHost) { - console.log('Guests cannot control playback'); + if (_roomMode && !_isRoomHost && !hasControlPermission()) { + showPermissionToast('You need playback control permission'); return; } @@ -411,12 +411,14 @@ const AnimePlayer = (function() { if (els.video.paused) { els.video.play().catch(() => {}); - if (_roomMode && _isRoomHost) { + + if (_roomMode && (_isRoomHost || hasControlPermission())) { sendRoomEvent('play', { currentTime: els.video.currentTime }); } } else { els.video.pause(); - if (_roomMode && _isRoomHost) { + + if (_roomMode && (_isRoomHost || hasControlPermission())) { sendRoomEvent('pause', { currentTime: els.video.currentTime }); } } @@ -460,7 +462,7 @@ const AnimePlayer = (function() { function seekToPosition(e) { if (!els.video || !els.progressContainer) return; - if (_roomMode && !_isRoomHost) return; + if (_roomMode && !_isRoomHost && !hasControlPermission()) return; const rect = els.progressContainer.getBoundingClientRect(); const pos = (e.clientX - rect.left) / rect.width; @@ -468,8 +470,7 @@ const AnimePlayer = (function() { els.video.currentTime = newTime; - // En room mode, enviar evento de seek - if (_roomMode && _isRoomHost) { + if (_roomMode && (_isRoomHost || hasControlPermission())) { sendRoomEvent('seek', { currentTime: newTime }); } } @@ -484,29 +485,63 @@ const AnimePlayer = (function() { function seekRelative(seconds) { if (!els.video) return; - if (_roomMode && !_isRoomHost) return; + if (_roomMode && !_isRoomHost && !hasControlPermission()) { + showPermissionToast('You need playback control permission'); + return; + } const newTime = Math.max(0, Math.min(els.video.duration, els.video.currentTime + seconds)); els.video.currentTime = newTime; - // En room mode, enviar evento de seek - if (_roomMode && _isRoomHost) { + if (_roomMode && (_isRoomHost || hasControlPermission())) { sendRoomEvent('seek', { currentTime: newTime }); } } + function seekToPercent(percent) { if (!els.video) return; - if (_roomMode && !_isRoomHost) return; - + if (_roomMode && !_isRoomHost && !hasControlPermission()) { + showPermissionToast('You need playback control permission'); + return; + } const newTime = els.video.duration * percent; els.video.currentTime = newTime; - // En room mode, enviar evento de seek - if (_roomMode && _isRoomHost) { + if (_roomMode && (_isRoomHost || hasControlPermission())) { sendRoomEvent('seek', { currentTime: newTime }); } } + function hasControlPermission() { + return window.__userPermissions?.canControl || false; + } + + function showPermissionToast(message) { + const toast = document.createElement('div'); + toast.className = 'permission-toast'; + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(239, 68, 68, 0.95); + color: white; + padding: 16px 24px; + border-radius: 10px; + font-weight: 600; + z-index: 10000; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); + animation: fadeIn 0.3s ease; + `; + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'fadeOut 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, 2500); + } + // Video event handlers function onPlay() { if (els.playPauseBtn) { @@ -589,7 +624,9 @@ const AnimePlayer = (function() { } function sendRoomEvent(eventType, data = {}) { - if (!_roomMode || !_isRoomHost || !_roomWebSocket) return; + if (!_roomMode || !_roomWebSocket) return; + if (!_isRoomHost && !hasControlPermission()) return; + if (_roomWebSocket.readyState !== WebSocket.OPEN) return; console.log('Sending room event:', eventType, data); diff --git a/desktop/src/scripts/room.js b/desktop/src/scripts/room.js index 062441b..94aae86 100644 --- a/desktop/src/scripts/room.js +++ b/desktop/src/scripts/room.js @@ -232,10 +232,15 @@ const RoomsApp = (function() { if (elements.btnAddQueue) { elements.btnAddQueue.onclick = () => { + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + if (!canManageQueue) { + showSystemToast('You need queue management permission'); + return; + } + if (selectedEpisodes.size === 0) return; const sortedEps = Array.from(selectedEpisodes).sort((a, b) => a - b); - const currentProvider = elements.roomExtSelect ? elements.roomExtSelect.value : 'gogoanime'; const originalText = elements.btnAddQueue.innerHTML; @@ -279,7 +284,9 @@ const RoomsApp = (function() { } if (elements.roomSdToggle) { elements.roomSdToggle.onclick = () => { - if (!isHost) return; + const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false); + + if (!hasAccess) return; const currentState = elements.roomSdToggle.getAttribute('data-state'); const newState = currentState === 'sub' ? 'dub' : 'sub'; @@ -338,7 +345,9 @@ const RoomsApp = (function() { // --- QUICK CONTROLS LOGIC (Header) --- async function populateQuickControls() { - if (!isHost || !selectedAnimeData) return; + const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false); + + if (!hasAccess || !selectedAnimeData) return; if (!extensionsReady) return; elements.roomExtSelect.innerHTML = ''; @@ -356,7 +365,8 @@ const RoomsApp = (function() { } async function onQuickExtensionChange(e, silent = false) { - if (!isHost) return; + const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false); + if (!hasAccess) return; const ext = elements.roomExtSelect.value; const settings = extensionsStore.settings[ext]; @@ -433,7 +443,9 @@ const RoomsApp = (function() { } function onQuickServerChange() { - if (!isHost) return; + const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false); + if (!hasAccess) return; + launchStream(false); } @@ -488,6 +500,28 @@ const RoomsApp = (function() { if(elements.btnLaunch) elements.btnLaunch.disabled = false; if(elements.btnAddQueue) elements.btnAddQueue.disabled = false; + if (extensionsReady && elements.selExtension) { + if (elements.selExtension.options.length <= 1) { + elements.selExtension.innerHTML = ''; + + extensionsStore.list.forEach(ext => { + const opt = document.createElement('option'); + opt.value = ext; + opt.textContent = ext[0].toUpperCase() + ext.slice(1); + elements.selExtension.appendChild(opt); + }); + + let defaultExt = selectedAnimeData.source || 'anilist'; + if (!extensionsStore.list.includes(defaultExt) && extensionsStore.list.length > 0) { + defaultExt = extensionsStore.list[0]; + } + + elements.selExtension.value = defaultExt; + + handleModalExtensionChange(); + } + } + setupConfigListeners(); elements.stepSearch.style.display = 'none'; @@ -495,7 +529,7 @@ const RoomsApp = (function() { } function setupConfigListeners() { - elements.epInc.onclick = () => { + elements.epInc.onclick = () => { elements.inpEpisode.value = parseInt(elements.inpEpisode.value || 0) + 1; }; elements.epDec.onclick = () => { @@ -625,12 +659,24 @@ const RoomsApp = (function() { let episodeToPlay = activeContext.episode; if (fromModal && elements.inpEpisode) episodeToPlay = elements.inpEpisode.value; - const ext = overrides.forceExtension || elements.roomExtSelect.value || activeContext.extension; - const server = overrides.forceServer || elements.roomServerSelect.value || activeContext.server; - const category = elements.roomSdToggle.getAttribute('data-state') || activeContext.category; + const ext = overrides.forceExtension || + (fromModal ? (configState.extension || elements.selExtension?.value) : null) || + activeContext.extension || + (elements.roomExtSelect ? elements.roomExtSelect.value : null); + + const server = overrides.forceServer || + (fromModal ? (configState.server || elements.selServer?.value) : null) || + activeContext.server || + (elements.roomServerSelect ? elements.roomServerSelect.value : null); + + const category = elements.roomSdToggle?.getAttribute('data-state') || activeContext.category || 'sub'; if (!ext || !server) { - console.warn("Faltan datos (ext o server) para lanzar el stream"); + console.warn("Faltan datos (ext o server).", {ext, server}); + if (fromModal && elements.configError) { + elements.configError.textContent = "Please select an extension and server."; + elements.configError.style.display = 'block'; + } return; } @@ -850,7 +896,6 @@ const RoomsApp = (function() { if (context.extension && elements.roomExtSelect.value !== context.extension) { elements.roomExtSelect.value = context.extension; - // Importante: Cargar los servidores de esa extensión sin disparar otro play await onQuickExtensionChange(null, true); } @@ -918,7 +963,7 @@ const RoomsApp = (function() { episode: ep, title: currentAnimeDetails.title.userPreferred, server: document.getElementById('room-server-select').value || 'gogoanime', - category: 'sub' // o lo que esté seleccionado + category: 'sub' } })); @@ -1081,6 +1126,18 @@ const RoomsApp = (function() { currentUserId = data.userId; currentUsername = data.username; isGuest = data.isGuest; + + if (data.room && data.room.users) { + const currentUser = data.room.users.find(u => u.id === data.userId); + if (currentUser) { + window.__userPermissions = currentUser.permissions || { + canControl: false, + canManageQueue: false + }; + } + } + + if (data.room.queue) renderQueue(data.room.queue); updateRoomUI(data.room); if (data.room.currentVideo) { @@ -1109,6 +1166,7 @@ const RoomsApp = (function() { break; case 'users_update': + saveRoomData({ users: data.users }); renderUsersList(data.users); break; @@ -1127,6 +1185,32 @@ const RoomsApp = (function() { updateUsersList(); break; + case 'permissions_updated': + saveRoomData({ users: data.users }); + + const updatedUser = data.users.find(u => u.id === currentUserId); + if (updatedUser) { + window.__userPermissions = updatedUser.permissions || { + canControl: false, + canManageQueue: false + }; + + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + if (elements.selectAnimeBtn) { + elements.selectAnimeBtn.style.display = canManageQueue ? 'flex' : 'none'; + } + + if (currentQueue.length > 0) { + renderQueue(currentQueue); + } + } + + if (permissionsModal && permissionsModal.classList.contains('show')) { + updatePermissionsList(); + } + + renderUsersList(data.users); + break; case 'chat': addChatMessage(data); const isChatHidden = elements.roomLayout.classList.contains('chat-hidden'); @@ -1210,33 +1294,34 @@ const RoomsApp = (function() { return; } + const canManageQueue = isHost || (window.__userPermissions && window.__userPermissions.canManageQueue); elements.queueList.innerHTML = queueItems.map((item, index) => { let actionsHtml = ''; - if (isHost) { + if (canManageQueue) { const isFirst = index === 0; const isLast = index === queueItems.length - 1; actionsHtml = ` -
- -
- ${!isFirst ? ` - ` : ''} - ${!isLast ? ` - ` : ''} -
- +
+ +
+ ${!isFirst ? ` + ` : ''} + ${!isLast ? ` + ` : ''}
- `; + +
+ `; } return ` @@ -1248,11 +1333,17 @@ const RoomsApp = (function() {
${actionsHtml} - `; + `; }).join(''); } window.playQueueItem = async function(uid) { + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + if (!canManageQueue) { + showSystemToast('You need queue management permission'); + return; + } + const item = currentQueue.find(i => i.uid === uid); if (!item) return; @@ -1305,12 +1396,25 @@ const RoomsApp = (function() { }; window.moveQueueItem = function(uid, direction) { + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + if (!canManageQueue) { + showSystemToast('You need queue management permission'); + return; + } + if(ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'queue_move', itemUid: uid, direction })); } }; + window.removeQueueItem = function(uid) { + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + if (!canManageQueue) { + showSystemToast('You need queue management permission'); + return; + } + if(ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'queue_remove', itemUid: uid })); } @@ -1321,17 +1425,39 @@ const RoomsApp = (function() { elements.roomViewers.textContent = `${room.users.length}`; const currentUser = room.users.find(u => u.id === currentUserId); + + if (currentUser) { + window.__userPermissions = currentUser.permissions || { canControl: false, canManageQueue: false }; + } isHost = currentUser?.isHost || false; - if (elements.selectAnimeBtn) elements.selectAnimeBtn.style.display = isHost ? 'flex' : 'none'; - if (elements.hostControls) elements.hostControls.style.display = isHost ? 'flex' : 'none'; + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + + const canControlPlayer = isHost || (window.__userPermissions?.canControl || false); + + if (elements.selectAnimeBtn) { + elements.selectAnimeBtn.style.display = canManageQueue ? 'flex' : 'none'; + } + + if (elements.hostControls) { + elements.hostControls.style.display = canManageQueue ? 'flex' : 'none'; + } if (window.AnimePlayer && typeof window.AnimePlayer.setRoomHost === 'function') { - window.AnimePlayer.setRoomHost(isHost); + window.AnimePlayer.setRoomHost(canControlPlayer); } if (isHost) { initGlobalControls(); + initPermissionsUI(); + } + else if (canManageQueue) { + initGlobalControls(); + } + + if (canManageQueue && room.metadata) { + if(!selectedAnimeData) selectedAnimeData = { ...room.metadata, source: 'anilist' }; + populateQuickControls(); } const copyInviteBtn = document.getElementById('copy-invite-btn'); @@ -1387,7 +1513,8 @@ const RoomsApp = (function() { } async function initGlobalControls() { - if (!isHost || !extensionsReady) return; + const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false); + if (!hasAccess || !extensionsReady) return; if (elements.roomExtSelect.options.length > 1) return; @@ -1820,6 +1947,274 @@ const RoomsApp = (function() { return div.innerHTML; } + let permissionsModal = null; + + function initPermissionsUI() { + if (!isHost) return; + + if (!permissionsModal) { + permissionsModal = document.createElement('div'); + permissionsModal.className = 'modal-overlay'; + permissionsModal.id = 'permissions-modal'; + permissionsModal.innerHTML = ` + + `; + document.body.appendChild(permissionsModal); + } + + const headerRight = document.querySelector('.header-right'); + if (headerRight && !document.getElementById('permissions-btn')) { + const permBtn = document.createElement('button'); + permBtn.id = 'permissions-btn'; + permBtn.className = 'btn-icon-glass'; + permBtn.title = 'Manage Permissions'; + permBtn.innerHTML = ` + + + + + `; + permBtn.onclick = openPermissionsModal; + + const searchBtn = document.getElementById('select-anime-btn'); + if (searchBtn) { + headerRight.insertBefore(permBtn, searchBtn); + } else { + headerRight.appendChild(permBtn); + } + } + } + + function openPermissionsModal() { + if (!permissionsModal || !isHost) return; + + updatePermissionsList(); + permissionsModal.classList.add('show'); + } + + window.closePermissionsModal = function() { + if (permissionsModal) { + permissionsModal.classList.remove('show'); + } + }; + + function updatePermissionsList() { + const list = document.getElementById('permissions-list'); + if (!list) return; + + const room = getCurrentRoomData(); + + if (!room || !room.users) { + list.innerHTML = '

No active users

'; + return; + } + + list.innerHTML = room.users + .filter(u => !u.isHost) + .map(user => createPermissionCard(user)) + .join(''); + } + + function createPermissionCard(user) { + const perms = user.permissions || { canControl: false, canManageQueue: false }; + + return ` +
+ + +
+ + + +
+ + +
+ `; + } + + window.togglePermission = function(userId, permission, value) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + console.log(`Toggling permission ${permission} to ${value} for user ${userId}`); + + const permissions = {}; + permissions[permission] = value; + + ws.send(JSON.stringify({ + type: 'update_permissions', + targetUserId: userId, + permissions + })); + + showSystemToast(`Permission ${value ? 'granted' : 'revoked'}`); + }; + + window.banUser = function(userId) { + const room = getCurrentRoomData(); + const user = room?.users?.find(u => u.id === userId); + + if (!user) return; + + const confirmed = confirm(`Ban ${user.username} from this room? They won't be able to rejoin.`); + if (!confirmed) return; + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'ban_user', + targetUserId: userId + })); + } + + showSystemToast(`${user.username} has been banned`); + setTimeout(() => updatePermissionsList(), 500); + }; + + function getCurrentRoomData() { + return window.__currentRoomData || null; + } + + function saveRoomData(roomData) { + window.__currentRoomData = roomData; + + if (roomData.users && currentUserId) { + const currentUser = roomData.users.find(u => u.id === currentUserId); + if (currentUser && currentUser.permissions) { + window.__userPermissions = currentUser.permissions; + + console.log('Permissions updated for current user:', window.__userPermissions); + } + } + } + + const originalHandleMessage = handleWebSocketMessage; + handleWebSocketMessage = function(data) { + switch(data.type) { + case 'permissions_updated': + saveRoomData({ users: data.users }); + + const updatedUser = data.users.find(u => u.id === currentUserId); + if (updatedUser) { + window.__userPermissions = updatedUser.permissions || { + canControl: false, + canManageQueue: false + }; + + console.log('My permissions updated:', window.__userPermissions); + + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + + if (elements.selectAnimeBtn) { + elements.selectAnimeBtn.style.display = canManageQueue ? 'flex' : 'none'; + } + + if (elements.hostControls) { + elements.hostControls.style.display = canManageQueue ? 'flex' : 'none'; + } + + if (canManageQueue) { + initGlobalControls(); + populateQuickControls(); + } + + if (!canManageQueue && elements.animeSearchModal && elements.animeSearchModal.classList.contains('show')) { + closeAnimeSearchModal(); + showSystemToast("Permissions updated: Access revoked"); + } + + if (currentQueue && currentQueue.length > 0) { + console.log('Forcing queue re-render due to permission change'); + renderQueue(currentQueue); + } + } + + if (permissionsModal && permissionsModal.classList.contains('show')) { + updatePermissionsList(); + } + + renderUsersList(data.users); + break; + + case 'banned': + alert(data.message || 'You have been banned from this room'); + window.location.href = '/anime'; + break; + + case 'users_update': + saveRoomData({ users: data.users }); + break; + + default: + originalHandleMessage(data); + } + }; + + const originalUpdateRoomUI = updateRoomUI; + updateRoomUI = function(room) { + originalUpdateRoomUI(room); + + if (isHost) { + initPermissionsUI(); + } + }; + return { init }; })(); diff --git a/desktop/views/css/room.css b/desktop/views/css/room.css index 457a0f6..38a5066 100644 --- a/desktop/views/css/room.css +++ b/desktop/views/css/room.css @@ -627,4 +627,227 @@ input[type=number] { -moz-appearance: textfield; } .config-sidebar { width: 100%; flex-direction: row; } .config-cover { width: 80px; } .ep-control { width: auto; flex: 1; } +} + +/* ========================================= + PERMISSIONS & MODERATION + ========================================= */ + +.permissions-content { + max-width: 700px; + width: 90%; + max-height: 85vh; + overflow-y: auto; +} + +.permissions-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 30px; +} + +.permission-card { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + transition: all 0.2s; +} + +.permission-card:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); +} + +.user-info-section { + display: flex; + align-items: center; + gap: 12px; + min-width: 180px; +} + +.user-avatar-small { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--brand-gradient); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: white; + font-size: 0.9rem; + flex-shrink: 0; +} + +.user-avatar-small img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.user-details { + display: flex; + flex-direction: column; + gap: 4px; +} + +.user-name-text { + font-weight: 600; + color: white; + font-size: 0.95rem; +} + +.guest-badge { + font-size: 0.7rem; + background: rgba(255, 255, 255, 0.1); + color: #aaa; + padding: 2px 8px; + border-radius: 4px; + font-weight: 600; + width: fit-content; +} + +.permissions-toggles { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; +} + +.perm-toggle { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; +} + +.perm-toggle input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--brand-color); +} + +.perm-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.8); + transition: color 0.2s; +} + +.perm-toggle:hover .perm-label { + color: white; +} + +.perm-label svg { + opacity: 0.6; +} + +.ban-btn { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #ef4444; + padding: 8px 16px; + border-radius: 8px; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s; + white-space: nowrap; +} + +.ban-btn:hover { + background: rgba(239, 68, 68, 0.2); + border-color: #ef4444; + transform: translateY(-1px); +} + +.banned-ips-section { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.section-title { + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 1px; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 12px; + font-weight: 700; +} + +.banned-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.banned-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + background: rgba(239, 68, 68, 0.05); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 8px; +} + +.banned-ip { + font-family: monospace; + color: #ef4444; + font-size: 0.9rem; +} + +.unban-btn { + background: transparent; + border: 1px solid rgba(74, 222, 128, 0.3); + color: #4ade80; + padding: 4px 12px; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.unban-btn:hover { + background: rgba(74, 222, 128, 0.1); + border-color: #4ade80; +} + +.empty-state { + text-align: center; + color: #666; + font-style: italic; + padding: 20px; +} + +/* Responsive */ +@media (max-width: 768px) { + .permission-card { + flex-direction: column; + align-items: stretch; + } + + .user-info-section { + min-width: auto; + } + + .ban-btn { + width: 100%; + justify-content: center; + } } \ No newline at end of file diff --git a/docker/src/api/rooms/rooms.service.ts b/docker/src/api/rooms/rooms.service.ts index 4d6cf95..95139b3 100644 --- a/docker/src/api/rooms/rooms.service.ts +++ b/docker/src/api/rooms/rooms.service.ts @@ -1,6 +1,11 @@ import crypto from 'crypto'; import { closeTunnelIfUnused } from "./tunnel.manager"; +interface RoomPermissions { + canControl: boolean; + canManageQueue: boolean; +} + interface RoomUser { id: string; username: string; @@ -8,6 +13,8 @@ interface RoomUser { isHost: boolean; isGuest: boolean; userId?: number; + permissions?: RoomPermissions; + ipAddress?: string; } interface SourceContext { @@ -55,8 +62,14 @@ interface RoomData { exposed: boolean; publicUrl?: string; queue: QueueItem[]; + bannedIPs: Set; } +export const DEFAULT_GUEST_PERMISSIONS: RoomPermissions = { + canControl: false, + canManageQueue: false +}; + const rooms = new Map(); export function generateRoomId(): string { @@ -77,13 +90,81 @@ export function createRoom(name: string, host: RoomUser, password?: string, expo metadata: null, exposed, publicUrl, - queue: [] + queue: [], + bannedIPs: new Set() // NUEVO }; rooms.set(roomId, room); return room; } +export function updateUserPermissions(roomId: string, userId: string, permissions: Partial): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + const user: any = room.users.get(userId); + if (!user) return false; + + if (user.isHost) return false; + + user.permissions = { + ...user.permissions, + ...permissions + }; + + return true; +} + +export function banUserIP(roomId: string, ipAddress: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + room.bannedIPs.add(ipAddress); + + // Remover a todos los usuarios con esa IP + Array.from(room.users.values()).forEach(user => { + if (user.ipAddress === ipAddress) { + removeUserFromRoom(roomId, user.id); + } + }); + + return true; +} + +export function unbanUserIP(roomId: string, ipAddress: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + return room.bannedIPs.delete(ipAddress); +} + +export function isIPBanned(roomId: string, ipAddress: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + return room.bannedIPs.has(ipAddress); +} + +export function getBannedIPs(roomId: string): string[] { + const room = rooms.get(roomId); + if (!room) return []; + return Array.from(room.bannedIPs); +} + +export function hasPermission(roomId: string, userId: string, permission: keyof RoomPermissions): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + const user = room.users.get(userId); + if (!user) return false; + + // El host siempre tiene todos los permisos + if (user.isHost) return true; + + // Si no tiene permisos definidos, usar defaults + const userPerms = user.permissions || DEFAULT_GUEST_PERMISSIONS; + return userPerms[permission] || false; +} + export function getRoom(roomId: string): RoomData | null { return rooms.get(roomId) || null; } diff --git a/docker/src/api/rooms/rooms.websocket.ts b/docker/src/api/rooms/rooms.websocket.ts index f50bb63..2b1a699 100644 --- a/docker/src/api/rooms/rooms.websocket.ts +++ b/docker/src/api/rooms/rooms.websocket.ts @@ -13,14 +13,13 @@ interface WSClient { const clients = new Map(); -interface WSParams { - roomId: string; -} -interface WSQuery { - token?: string; - guestName?: string; - password?: string; +function getClientIP(req: any): string { + return req.headers['x-forwarded-for']?.split(',')[0].trim() || + req.headers['x-real-ip'] || + req.connection?.remoteAddress || + req.socket?.remoteAddress || + 'unknown'; } export function setupRoomWebSocket(fastify: FastifyInstance) { @@ -44,13 +43,14 @@ async function handleWebSocketConnection(connection: any, req: any) { const guestName = req.query.guestName; const password = req.query.password; + const clientIP = getClientIP(req); // NUEVO + let userId: string; let username: string; let avatar: string | undefined; let isGuest = false; let realUserId: any; - // Verificar si la sala existe const room = roomService.getRoom(roomId); if (!room) { socket.send(JSON.stringify({ @@ -61,6 +61,24 @@ async function handleWebSocketConnection(connection: any, req: any) { return; } + if (roomService.isIPBanned(roomId, clientIP)) { + socket.send(JSON.stringify({ + type: 'error', + message: 'You have been banned from this room' + })); + socket.close(); + return; + } + + if (!room) { + socket.send(JSON.stringify({ + type: 'error', + message: 'Room not found' + })); + socket.close(); + return; + } + // Verificar contraseña si existe if (room.password) { if (!password || !roomService.verifyRoomPassword(roomId, password)) { @@ -134,14 +152,15 @@ async function handleWebSocketConnection(connection: any, req: any) { isHost }); - // Agregar usuario a la sala const userInRoom = { id: userId, username, avatar, - isHost: isHost, // ← CORREGIDO: Usar la verificación correcta + isHost: isHost, isGuest, - userId: realUserId + userId: realUserId, + ipAddress: clientIP, // NUEVO + permissions: isHost ? undefined : { ...roomService.DEFAULT_GUEST_PERMISSIONS } }; roomService.addUserToRoom(roomId, userInRoom); @@ -160,6 +179,7 @@ async function handleWebSocketConnection(connection: any, req: any) { userId, username, isGuest, + isHost, // NUEVO: Enviar explícitamente room: { id: room.id, name: room.name, @@ -168,7 +188,8 @@ async function handleWebSocketConnection(connection: any, req: any) { username: u.username, avatar: u.avatar, isHost: u.isHost, - isGuest: u.isGuest + isGuest: u.isGuest, + permissions: u.permissions // NUEVO })), currentVideo: room.currentVideo, queue: room.queue || [] @@ -212,6 +233,9 @@ function handleMessage(roomId: string, userId: string, data: any) { const room = roomService.getRoom(roomId); if (!room) return; + const user = room.users.get(userId); + if (!user) return; + console.log('Handling message:', data.type, 'from user:', userId, 'isHost:', room.host.id === userId); switch (data.type) { @@ -226,8 +250,65 @@ function handleMessage(roomId: string, userId: string, data: any) { }); break; + case 'update_permissions': + if (!user.isHost) { + console.warn('Non-host attempted to update permissions'); + return; + } + + const success = roomService.updateUserPermissions( + roomId, + data.targetUserId, + data.permissions + ); + + if (success) { + const updatedRoom = roomService.getRoom(roomId); + if (updatedRoom) { + broadcastToRoom(roomId, { + type: 'permissions_updated', + users: Array.from(updatedRoom.users.values()).map(u => ({ + id: u.id, + username: u.username, + avatar: u.avatar, + isHost: u.isHost, + isGuest: u.isGuest, + permissions: u.permissions + })) + }); + } + } + break; + + // NUEVO: Baneo de usuarios (solo host) + case 'ban_user': + if (!user.isHost) { + console.warn('Non-host attempted to ban user'); + return; + } + + const targetUser = room.users.get(data.targetUserId); + if (targetUser && targetUser.ipAddress) { + roomService.banUserIP(roomId, targetUser.ipAddress); + + // Cerrar conexión del usuario baneado + const targetClient = clients.get(data.targetUserId); + if (targetClient && targetClient.socket) { + targetClient.socket.send(JSON.stringify({ + type: 'banned', + message: 'You have been banned from this room' + })); + targetClient.socket.close(); + } + } + break; + + case 'queue_play_item': - if (room.host.id !== userId) return; + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) { + console.warn('User lacks permission for queue management'); + return; + } const itemToPlay = roomService.getAndRemoveQueueItem(roomId, data.itemUid); if (itemToPlay) { @@ -255,7 +336,9 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'queue_move': - if (room.host.id !== userId) return; + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) { + return; + } const moved = roomService.moveQueueItem(roomId, data.itemUid, data.direction); if (moved) { @@ -295,7 +378,14 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'video_update': - if (room.host.id !== userId) return; + const canUpdateVideo = user.isHost || + roomService.hasPermission(roomId, userId, 'canControl') || + roomService.hasPermission(roomId, userId, 'canManageQueue'); + + if (!canUpdateVideo) { + console.warn('User lacks permissions to update video'); + return; + } roomService.updateRoomVideo(roomId, data.video); roomService.updateRoomMetadata(roomId, data.metadata); @@ -308,13 +398,13 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'queue_add_batch': - if (room.host.id !== userId) return; + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) { + return; + } if (Array.isArray(data.items)) { - // Añadimos el índice (i) al forEach data.items.forEach((item: any, i: number) => { const newItem = { - // Añadimos el índice '_${i}' al UID para garantizar unicidad en milisegundos uid: `q_${Date.now()}_${i}_${Math.random().toString(36).substr(2, 5)}`, metadata: item.metadata, videoData: item.video, @@ -365,13 +455,11 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'play': - // Solo el host puede controlar la reproducción - if (room.host.id !== userId) { - console.warn('Non-host attempted play:', userId); + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) { + console.warn('User lacks control permissions'); return; } - console.log('Broadcasting play event to room:', roomId); broadcastToRoom(roomId, { type: 'play', currentTime: data.currentTime, @@ -380,8 +468,8 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'pause': - if (room.host.id !== userId) { - console.warn('Non-host attempted pause:', userId); + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) { + console.warn('User lacks control permissions for pause'); return; } @@ -394,8 +482,8 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'seek': - if (room.host.id !== userId) { - console.warn('Non-host attempted seek:', userId); + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) { + console.warn('User lacks control permissions for seek'); return; } @@ -436,19 +524,19 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'queue_add': - // Solo el host (o todos si quisieras) pueden añadir - if (room.host.id !== userId) return; + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) { + return; + } const newItem = { uid: `q_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`, metadata: data.metadata, - videoData: data.video, // El objeto que contiene url, type, subtitles + videoData: data.video, addedBy: room.users.get(userId)?.username || 'Unknown' }; roomService.addQueueItem(roomId, newItem); - // Avisar a todos que la cola cambió broadcastToRoom(roomId, { type: 'queue_update', queue: room.queue @@ -456,7 +544,10 @@ function handleMessage(roomId: string, userId: string, data: any) { break; case 'queue_remove': - if (room.host.id !== userId) return; + if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) { + return; + } + roomService.removeQueueItem(roomId, data.itemUid); broadcastToRoom(roomId, { type: 'queue_update', diff --git a/docker/src/scripts/anime/player.js b/docker/src/scripts/anime/player.js index a694675..2c1a133 100644 --- a/docker/src/scripts/anime/player.js +++ b/docker/src/scripts/anime/player.js @@ -402,8 +402,8 @@ const AnimePlayer = (function() { // Control functions function togglePlayPause() { - if (_roomMode && !_isRoomHost) { - console.log('Guests cannot control playback'); + if (_roomMode && !_isRoomHost && !hasControlPermission()) { + showPermissionToast('You need playback control permission'); return; } @@ -411,12 +411,14 @@ const AnimePlayer = (function() { if (els.video.paused) { els.video.play().catch(() => {}); - if (_roomMode && _isRoomHost) { + + if (_roomMode && (_isRoomHost || hasControlPermission())) { sendRoomEvent('play', { currentTime: els.video.currentTime }); } } else { els.video.pause(); - if (_roomMode && _isRoomHost) { + + if (_roomMode && (_isRoomHost || hasControlPermission())) { sendRoomEvent('pause', { currentTime: els.video.currentTime }); } } @@ -460,7 +462,7 @@ const AnimePlayer = (function() { function seekToPosition(e) { if (!els.video || !els.progressContainer) return; - if (_roomMode && !_isRoomHost) return; + if (_roomMode && !_isRoomHost && !hasControlPermission()) return; const rect = els.progressContainer.getBoundingClientRect(); const pos = (e.clientX - rect.left) / rect.width; @@ -468,8 +470,7 @@ const AnimePlayer = (function() { els.video.currentTime = newTime; - // En room mode, enviar evento de seek - if (_roomMode && _isRoomHost) { + if (_roomMode && (_isRoomHost || hasControlPermission())) { sendRoomEvent('seek', { currentTime: newTime }); } } @@ -484,29 +485,63 @@ const AnimePlayer = (function() { function seekRelative(seconds) { if (!els.video) return; - if (_roomMode && !_isRoomHost) return; + if (_roomMode && !_isRoomHost && !hasControlPermission()) { + showPermissionToast('You need playback control permission'); + return; + } const newTime = Math.max(0, Math.min(els.video.duration, els.video.currentTime + seconds)); els.video.currentTime = newTime; - // En room mode, enviar evento de seek - if (_roomMode && _isRoomHost) { + if (_roomMode && (_isRoomHost || hasControlPermission())) { sendRoomEvent('seek', { currentTime: newTime }); } } + function seekToPercent(percent) { if (!els.video) return; - if (_roomMode && !_isRoomHost) return; - + if (_roomMode && !_isRoomHost && !hasControlPermission()) { + showPermissionToast('You need playback control permission'); + return; + } const newTime = els.video.duration * percent; els.video.currentTime = newTime; - // En room mode, enviar evento de seek - if (_roomMode && _isRoomHost) { + if (_roomMode && (_isRoomHost || hasControlPermission())) { sendRoomEvent('seek', { currentTime: newTime }); } } + function hasControlPermission() { + return window.__userPermissions?.canControl || false; + } + + function showPermissionToast(message) { + const toast = document.createElement('div'); + toast.className = 'permission-toast'; + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(239, 68, 68, 0.95); + color: white; + padding: 16px 24px; + border-radius: 10px; + font-weight: 600; + z-index: 10000; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); + animation: fadeIn 0.3s ease; + `; + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'fadeOut 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, 2500); + } + // Video event handlers function onPlay() { if (els.playPauseBtn) { @@ -589,7 +624,9 @@ const AnimePlayer = (function() { } function sendRoomEvent(eventType, data = {}) { - if (!_roomMode || !_isRoomHost || !_roomWebSocket) return; + if (!_roomMode || !_roomWebSocket) return; + if (!_isRoomHost && !hasControlPermission()) return; + if (_roomWebSocket.readyState !== WebSocket.OPEN) return; console.log('Sending room event:', eventType, data); diff --git a/docker/src/scripts/room.js b/docker/src/scripts/room.js index 062441b..ad5698f 100644 --- a/docker/src/scripts/room.js +++ b/docker/src/scripts/room.js @@ -232,10 +232,15 @@ const RoomsApp = (function() { if (elements.btnAddQueue) { elements.btnAddQueue.onclick = () => { + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + if (!canManageQueue) { + showSystemToast('You need queue management permission'); + return; + } + if (selectedEpisodes.size === 0) return; const sortedEps = Array.from(selectedEpisodes).sort((a, b) => a - b); - const currentProvider = elements.roomExtSelect ? elements.roomExtSelect.value : 'gogoanime'; const originalText = elements.btnAddQueue.innerHTML; @@ -279,7 +284,9 @@ const RoomsApp = (function() { } if (elements.roomSdToggle) { elements.roomSdToggle.onclick = () => { - if (!isHost) return; + const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false); + + if (!hasAccess) return; const currentState = elements.roomSdToggle.getAttribute('data-state'); const newState = currentState === 'sub' ? 'dub' : 'sub'; @@ -338,7 +345,9 @@ const RoomsApp = (function() { // --- QUICK CONTROLS LOGIC (Header) --- async function populateQuickControls() { - if (!isHost || !selectedAnimeData) return; + const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false); + + if (!hasAccess || !selectedAnimeData) return; if (!extensionsReady) return; elements.roomExtSelect.innerHTML = ''; @@ -356,7 +365,8 @@ const RoomsApp = (function() { } async function onQuickExtensionChange(e, silent = false) { - if (!isHost) return; + const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false); + if (!hasAccess) return; const ext = elements.roomExtSelect.value; const settings = extensionsStore.settings[ext]; @@ -433,7 +443,9 @@ const RoomsApp = (function() { } function onQuickServerChange() { - if (!isHost) return; + const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false); + if (!hasAccess) return; + launchStream(false); } @@ -488,6 +500,28 @@ const RoomsApp = (function() { if(elements.btnLaunch) elements.btnLaunch.disabled = false; if(elements.btnAddQueue) elements.btnAddQueue.disabled = false; + if (extensionsReady && elements.selExtension) { + if (elements.selExtension.options.length <= 1) { + elements.selExtension.innerHTML = ''; + + extensionsStore.list.forEach(ext => { + const opt = document.createElement('option'); + opt.value = ext; + opt.textContent = ext[0].toUpperCase() + ext.slice(1); + elements.selExtension.appendChild(opt); + }); + + let defaultExt = selectedAnimeData.source || 'anilist'; + if (!extensionsStore.list.includes(defaultExt) && extensionsStore.list.length > 0) { + defaultExt = extensionsStore.list[0]; + } + + elements.selExtension.value = defaultExt; + + handleModalExtensionChange(); + } + } + setupConfigListeners(); elements.stepSearch.style.display = 'none'; @@ -625,12 +659,24 @@ const RoomsApp = (function() { let episodeToPlay = activeContext.episode; if (fromModal && elements.inpEpisode) episodeToPlay = elements.inpEpisode.value; - const ext = overrides.forceExtension || elements.roomExtSelect.value || activeContext.extension; - const server = overrides.forceServer || elements.roomServerSelect.value || activeContext.server; - const category = elements.roomSdToggle.getAttribute('data-state') || activeContext.category; + const ext = overrides.forceExtension || + (fromModal ? (configState.extension || elements.selExtension?.value) : null) || + activeContext.extension || + (elements.roomExtSelect ? elements.roomExtSelect.value : null); + + const server = overrides.forceServer || + (fromModal ? (configState.server || elements.selServer?.value) : null) || + activeContext.server || + (elements.roomServerSelect ? elements.roomServerSelect.value : null); + + const category = elements.roomSdToggle?.getAttribute('data-state') || activeContext.category || 'sub'; if (!ext || !server) { - console.warn("Faltan datos (ext o server) para lanzar el stream"); + console.warn("Faltan datos (ext o server).", {ext, server}); + if (fromModal && elements.configError) { + elements.configError.textContent = "Please select an extension and server."; + elements.configError.style.display = 'block'; + } return; } @@ -850,7 +896,6 @@ const RoomsApp = (function() { if (context.extension && elements.roomExtSelect.value !== context.extension) { elements.roomExtSelect.value = context.extension; - // Importante: Cargar los servidores de esa extensión sin disparar otro play await onQuickExtensionChange(null, true); } @@ -918,7 +963,7 @@ const RoomsApp = (function() { episode: ep, title: currentAnimeDetails.title.userPreferred, server: document.getElementById('room-server-select').value || 'gogoanime', - category: 'sub' // o lo que esté seleccionado + category: 'sub' } })); @@ -1081,6 +1126,18 @@ const RoomsApp = (function() { currentUserId = data.userId; currentUsername = data.username; isGuest = data.isGuest; + + if (data.room && data.room.users) { + const currentUser = data.room.users.find(u => u.id === data.userId); + if (currentUser) { + window.__userPermissions = currentUser.permissions || { + canControl: false, + canManageQueue: false + }; + } + } + + if (data.room.queue) renderQueue(data.room.queue); updateRoomUI(data.room); if (data.room.currentVideo) { @@ -1109,6 +1166,7 @@ const RoomsApp = (function() { break; case 'users_update': + saveRoomData({ users: data.users }); renderUsersList(data.users); break; @@ -1127,6 +1185,32 @@ const RoomsApp = (function() { updateUsersList(); break; + case 'permissions_updated': + saveRoomData({ users: data.users }); + + const updatedUser = data.users.find(u => u.id === currentUserId); + if (updatedUser) { + window.__userPermissions = updatedUser.permissions || { + canControl: false, + canManageQueue: false + }; + + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + if (elements.selectAnimeBtn) { + elements.selectAnimeBtn.style.display = canManageQueue ? 'flex' : 'none'; + } + + if (currentQueue.length > 0) { + renderQueue(currentQueue); + } + } + + if (permissionsModal && permissionsModal.classList.contains('show')) { + updatePermissionsList(); + } + + renderUsersList(data.users); + break; case 'chat': addChatMessage(data); const isChatHidden = elements.roomLayout.classList.contains('chat-hidden'); @@ -1210,33 +1294,34 @@ const RoomsApp = (function() { return; } + const canManageQueue = isHost || (window.__userPermissions && window.__userPermissions.canManageQueue); elements.queueList.innerHTML = queueItems.map((item, index) => { let actionsHtml = ''; - if (isHost) { + if (canManageQueue) { const isFirst = index === 0; const isLast = index === queueItems.length - 1; actionsHtml = ` -
- -
- ${!isFirst ? ` - ` : ''} - ${!isLast ? ` - ` : ''} -
- +
+ +
+ ${!isFirst ? ` + ` : ''} + ${!isLast ? ` + ` : ''}
- `; + +
+ `; } return ` @@ -1248,11 +1333,17 @@ const RoomsApp = (function() {
${actionsHtml} - `; + `; }).join(''); } window.playQueueItem = async function(uid) { + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + if (!canManageQueue) { + showSystemToast('You need queue management permission'); + return; + } + const item = currentQueue.find(i => i.uid === uid); if (!item) return; @@ -1305,12 +1396,25 @@ const RoomsApp = (function() { }; window.moveQueueItem = function(uid, direction) { + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + if (!canManageQueue) { + showSystemToast('You need queue management permission'); + return; + } + if(ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'queue_move', itemUid: uid, direction })); } }; + window.removeQueueItem = function(uid) { + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + if (!canManageQueue) { + showSystemToast('You need queue management permission'); + return; + } + if(ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'queue_remove', itemUid: uid })); } @@ -1321,17 +1425,39 @@ const RoomsApp = (function() { elements.roomViewers.textContent = `${room.users.length}`; const currentUser = room.users.find(u => u.id === currentUserId); + + if (currentUser) { + window.__userPermissions = currentUser.permissions || { canControl: false, canManageQueue: false }; + } isHost = currentUser?.isHost || false; - if (elements.selectAnimeBtn) elements.selectAnimeBtn.style.display = isHost ? 'flex' : 'none'; - if (elements.hostControls) elements.hostControls.style.display = isHost ? 'flex' : 'none'; + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + + const canControlPlayer = isHost || (window.__userPermissions?.canControl || false); + + if (elements.selectAnimeBtn) { + elements.selectAnimeBtn.style.display = canManageQueue ? 'flex' : 'none'; + } + + if (elements.hostControls) { + elements.hostControls.style.display = canManageQueue ? 'flex' : 'none'; + } if (window.AnimePlayer && typeof window.AnimePlayer.setRoomHost === 'function') { - window.AnimePlayer.setRoomHost(isHost); + window.AnimePlayer.setRoomHost(canControlPlayer); } if (isHost) { initGlobalControls(); + initPermissionsUI(); + } + else if (canManageQueue) { + initGlobalControls(); + } + + if (canManageQueue && room.metadata) { + if(!selectedAnimeData) selectedAnimeData = { ...room.metadata, source: 'anilist' }; + populateQuickControls(); } const copyInviteBtn = document.getElementById('copy-invite-btn'); @@ -1387,7 +1513,8 @@ const RoomsApp = (function() { } async function initGlobalControls() { - if (!isHost || !extensionsReady) return; + const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false); + if (!hasAccess || !extensionsReady) return; if (elements.roomExtSelect.options.length > 1) return; @@ -1820,6 +1947,274 @@ const RoomsApp = (function() { return div.innerHTML; } + let permissionsModal = null; + + function initPermissionsUI() { + if (!isHost) return; + + if (!permissionsModal) { + permissionsModal = document.createElement('div'); + permissionsModal.className = 'modal-overlay'; + permissionsModal.id = 'permissions-modal'; + permissionsModal.innerHTML = ` + + `; + document.body.appendChild(permissionsModal); + } + + const headerRight = document.querySelector('.header-right'); + if (headerRight && !document.getElementById('permissions-btn')) { + const permBtn = document.createElement('button'); + permBtn.id = 'permissions-btn'; + permBtn.className = 'btn-icon-glass'; + permBtn.title = 'Manage Permissions'; + permBtn.innerHTML = ` + + + + + `; + permBtn.onclick = openPermissionsModal; + + const searchBtn = document.getElementById('select-anime-btn'); + if (searchBtn) { + headerRight.insertBefore(permBtn, searchBtn); + } else { + headerRight.appendChild(permBtn); + } + } + } + + function openPermissionsModal() { + if (!permissionsModal || !isHost) return; + + updatePermissionsList(); + permissionsModal.classList.add('show'); + } + + window.closePermissionsModal = function() { + if (permissionsModal) { + permissionsModal.classList.remove('show'); + } + }; + + function updatePermissionsList() { + const list = document.getElementById('permissions-list'); + if (!list) return; + + const room = getCurrentRoomData(); + + if (!room || !room.users) { + list.innerHTML = '

No active users

'; + return; + } + + list.innerHTML = room.users + .filter(u => !u.isHost) + .map(user => createPermissionCard(user)) + .join(''); + } + + function createPermissionCard(user) { + const perms = user.permissions || { canControl: false, canManageQueue: false }; + + return ` +
+ + +
+ + + +
+ + +
+ `; + } + + window.togglePermission = function(userId, permission, value) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + console.log(`Toggling permission ${permission} to ${value} for user ${userId}`); + + const permissions = {}; + permissions[permission] = value; + + ws.send(JSON.stringify({ + type: 'update_permissions', + targetUserId: userId, + permissions + })); + + showSystemToast(`Permission ${value ? 'granted' : 'revoked'}`); + }; + + window.banUser = function(userId) { + const room = getCurrentRoomData(); + const user = room?.users?.find(u => u.id === userId); + + if (!user) return; + + const confirmed = confirm(`Ban ${user.username} from this room? They won't be able to rejoin.`); + if (!confirmed) return; + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'ban_user', + targetUserId: userId + })); + } + + showSystemToast(`${user.username} has been banned`); + setTimeout(() => updatePermissionsList(), 500); + }; + + function getCurrentRoomData() { + return window.__currentRoomData || null; + } + + function saveRoomData(roomData) { + window.__currentRoomData = roomData; + + if (roomData.users && currentUserId) { + const currentUser = roomData.users.find(u => u.id === currentUserId); + if (currentUser && currentUser.permissions) { + window.__userPermissions = currentUser.permissions; + + console.log('Permissions updated for current user:', window.__userPermissions); + } + } + } + + const originalHandleMessage = handleWebSocketMessage; + handleWebSocketMessage = function(data) { + switch(data.type) { + case 'permissions_updated': + saveRoomData({ users: data.users }); + + const updatedUser = data.users.find(u => u.id === currentUserId); + if (updatedUser) { + window.__userPermissions = updatedUser.permissions || { + canControl: false, + canManageQueue: false + }; + + console.log('My permissions updated:', window.__userPermissions); + + const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false); + + if (elements.selectAnimeBtn) { + elements.selectAnimeBtn.style.display = canManageQueue ? 'flex' : 'none'; + } + + if (elements.hostControls) { + elements.hostControls.style.display = canManageQueue ? 'flex' : 'none'; + } + + if (canManageQueue) { + initGlobalControls(); + populateQuickControls(); + } + + if (!canManageQueue && elements.animeSearchModal && elements.animeSearchModal.classList.contains('show')) { + closeAnimeSearchModal(); + showSystemToast("Permissions updated: Access revoked"); + } + + if (currentQueue && currentQueue.length > 0) { + console.log('Forcing queue re-render due to permission change'); + renderQueue(currentQueue); + } + } + + if (permissionsModal && permissionsModal.classList.contains('show')) { + updatePermissionsList(); + } + + renderUsersList(data.users); + break; + + case 'banned': + alert(data.message || 'You have been banned from this room'); + window.location.href = '/anime'; + break; + + case 'users_update': + saveRoomData({ users: data.users }); + break; + + default: + originalHandleMessage(data); + } + }; + + const originalUpdateRoomUI = updateRoomUI; + updateRoomUI = function(room) { + originalUpdateRoomUI(room); + + if (isHost) { + initPermissionsUI(); + } + }; + return { init }; })(); diff --git a/docker/views/css/room.css b/docker/views/css/room.css index 457a0f6..38a5066 100644 --- a/docker/views/css/room.css +++ b/docker/views/css/room.css @@ -627,4 +627,227 @@ input[type=number] { -moz-appearance: textfield; } .config-sidebar { width: 100%; flex-direction: row; } .config-cover { width: 80px; } .ep-control { width: auto; flex: 1; } +} + +/* ========================================= + PERMISSIONS & MODERATION + ========================================= */ + +.permissions-content { + max-width: 700px; + width: 90%; + max-height: 85vh; + overflow-y: auto; +} + +.permissions-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 30px; +} + +.permission-card { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + transition: all 0.2s; +} + +.permission-card:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); +} + +.user-info-section { + display: flex; + align-items: center; + gap: 12px; + min-width: 180px; +} + +.user-avatar-small { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--brand-gradient); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: white; + font-size: 0.9rem; + flex-shrink: 0; +} + +.user-avatar-small img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.user-details { + display: flex; + flex-direction: column; + gap: 4px; +} + +.user-name-text { + font-weight: 600; + color: white; + font-size: 0.95rem; +} + +.guest-badge { + font-size: 0.7rem; + background: rgba(255, 255, 255, 0.1); + color: #aaa; + padding: 2px 8px; + border-radius: 4px; + font-weight: 600; + width: fit-content; +} + +.permissions-toggles { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; +} + +.perm-toggle { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; +} + +.perm-toggle input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--brand-color); +} + +.perm-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.8); + transition: color 0.2s; +} + +.perm-toggle:hover .perm-label { + color: white; +} + +.perm-label svg { + opacity: 0.6; +} + +.ban-btn { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #ef4444; + padding: 8px 16px; + border-radius: 8px; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s; + white-space: nowrap; +} + +.ban-btn:hover { + background: rgba(239, 68, 68, 0.2); + border-color: #ef4444; + transform: translateY(-1px); +} + +.banned-ips-section { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.section-title { + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 1px; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 12px; + font-weight: 700; +} + +.banned-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.banned-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + background: rgba(239, 68, 68, 0.05); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 8px; +} + +.banned-ip { + font-family: monospace; + color: #ef4444; + font-size: 0.9rem; +} + +.unban-btn { + background: transparent; + border: 1px solid rgba(74, 222, 128, 0.3); + color: #4ade80; + padding: 4px 12px; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.unban-btn:hover { + background: rgba(74, 222, 128, 0.1); + border-color: #4ade80; +} + +.empty-state { + text-align: center; + color: #666; + font-style: italic; + padding: 20px; +} + +/* Responsive */ +@media (max-width: 768px) { + .permission-card { + flex-direction: column; + align-items: stretch; + } + + .user-info-section { + min-width: auto; + } + + .ban-btn { + width: 100%; + justify-content: center; + } } \ No newline at end of file