From 8d0032ad59105b896d64522e0f6d4fda17e4ec75 Mon Sep 17 00:00:00 2001 From: Aron Wiederkehr Date: Mon, 28 Apr 2025 11:16:35 +0200 Subject: [PATCH 01/13] feat: Add admin mode functionality with login and status checks - Implemented admin mode configuration in docker-compose.yml - Added AdminContext and AdminModal components for managing admin state - Integrated admin login functionality in AuthController - Updated App component to handle admin status and modal display - Enhanced ChannelList, ChannelModal, and TvPlaylistModal to support admin features - Added sensitive information handling in ChannelModal and TvPlaylistModal - Modified SocketService methods to include admin checks for channel and playlist operations --- backend/controllers/AuthController.js | 32 ++ backend/server.js | 21 +- backend/socket/ChannelSocketHandler.js | 32 +- backend/socket/PlaylistSocketHandler.js | 36 +- docker-compose.yml | 3 + frontend/src/App.tsx | 397 ++++++++++-------- frontend/src/components/ChannelList.tsx | 18 +- frontend/src/components/TvPlaylistModal.tsx | 37 +- .../components/add_channel/ChannelModal.tsx | 149 +++++-- .../add_channel/CustomHeaderInput.tsx | 9 +- .../src/components/admin/AdminContext.tsx | 32 ++ frontend/src/components/admin/AdminModal.tsx | 124 ++++++ frontend/src/services/SocketService.ts | 24 +- 13 files changed, 672 insertions(+), 242 deletions(-) create mode 100644 backend/controllers/AuthController.js create mode 100644 frontend/src/components/admin/AdminContext.tsx create mode 100644 frontend/src/components/admin/AdminModal.tsx diff --git a/backend/controllers/AuthController.js b/backend/controllers/AuthController.js new file mode 100644 index 0000000..51d7c04 --- /dev/null +++ b/backend/controllers/AuthController.js @@ -0,0 +1,32 @@ +require('dotenv').config(); + +const ADMIN_ENABLED = process.env.ADMIN_ENABLED === 'true'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; + +module.exports = { + adminLogin(req, res) { + if (!ADMIN_ENABLED || ADMIN_PASSWORD === undefined) { + return res.status(403).json({ + success: false, + message: 'Admin mode is disabled on this server' + }); + } + + const { password } = req.body; + + if (password === ADMIN_PASSWORD) { + return res.json({ success: true }); + } else { + return res.status(401).json({ + success: false, + message: 'Invalid password' + }); + } + }, + + checkAdminStatus(req, res) { + res.json({ + enabled: ADMIN_ENABLED + }); + } +}; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 0f118e4..491ce3e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,6 +8,7 @@ const ChannelSocketHandler = require('./socket/ChannelSocketHandler'); const proxyController = require('./controllers/ProxyController'); const centralChannelController = require('./controllers/CentralChannelController'); const channelController = require('./controllers/ChannelController'); +const authController = require('./controllers/AuthController'); const streamController = require('./services/restream/StreamController'); const ChannelService = require('./services/ChannelService'); const PlaylistSocketHandler = require('./socket/PlaylistSocketHandler'); @@ -18,6 +19,24 @@ dotenv.config(); const app = express(); app.use(express.json()); +// CORS middleware +app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + if (req.method === 'OPTIONS') { + return res.sendStatus(200); + } + next(); +}); + +// Auth routes +const authRouter = express.Router(); +authRouter.post('/admin-login', authController.adminLogin); +authRouter.get('/admin-status', authController.checkAdminStatus); +app.use('/api/auth', authRouter); + +// Channel routes const apiRouter = express.Router(); apiRouter.get('/', channelController.getChannels); apiRouter.get('/current', channelController.getCurrentChannel); @@ -70,4 +89,4 @@ io.on('connection', socket => { PlaylistSocketHandler(io, socket); ChatSocketHandler(io, socket); -}) +}) \ No newline at end of file diff --git a/backend/socket/ChannelSocketHandler.js b/backend/socket/ChannelSocketHandler.js index 0627d7b..9d71bd9 100644 --- a/backend/socket/ChannelSocketHandler.js +++ b/backend/socket/ChannelSocketHandler.js @@ -1,9 +1,17 @@ const ChannelService = require('../services/ChannelService'); +require('dotenv').config(); + +const ADMIN_ENABLED = process.env.ADMIN_ENABLED === 'true'; module.exports = (io, socket) => { - - socket.on('add-channel', ({ name, url, avatar, mode, headersJson }) => { + // Check if admin mode is required for channel modifications + socket.on('add-channel', ({ name, url, avatar, mode, headersJson, isAdmin }) => { try { + // If admin mode is enabled but user is not admin, reject the operation + if (ADMIN_ENABLED && !isAdmin) { + return socket.emit('app-error', { message: 'Admin access required to add channels' }); + } + console.log('Adding solo channel:', url); const newChannel = ChannelService.addChannel({ name: name, url: url, avatar: avatar, mode: mode, headersJson: headersJson }); io.emit('channel-added', newChannel); // Broadcast to all clients @@ -22,8 +30,17 @@ module.exports = (io, socket) => { } }); - socket.on('delete-channel', async (id) => { + socket.on('delete-channel', async (data) => { try { + // Parse input to handle both old format (just id) and new format with admin flag + const id = typeof data === 'object' ? data.id : data; + const isAdmin = typeof data === 'object' ? data.isAdmin : false; + + // If admin mode is enabled but user is not admin, reject the operation + if (ADMIN_ENABLED && !isAdmin) { + return socket.emit('app-error', { message: 'Admin access required to delete channels' }); + } + const lastChannel = ChannelService.getCurrentChannel(); const current = await ChannelService.deleteChannel(id); io.emit('channel-deleted', id); // Broadcast to all clients @@ -34,8 +51,13 @@ module.exports = (io, socket) => { } }); - socket.on('update-channel', async ({ id, updatedAttributes }) => { + socket.on('update-channel', async ({ id, updatedAttributes, isAdmin }) => { try { + // If admin mode is enabled but user is not admin, reject the operation + if (ADMIN_ENABLED && !isAdmin) { + return socket.emit('app-error', { message: 'Admin access required to update channels' }); + } + const updatedChannel = await ChannelService.updateChannel(id, updatedAttributes); io.emit('channel-updated', updatedChannel); // Broadcast to all clients } catch (err) { @@ -43,4 +65,4 @@ module.exports = (io, socket) => { socket.emit('app-error', { message: err.message }); } }); -}; +}; \ No newline at end of file diff --git a/backend/socket/PlaylistSocketHandler.js b/backend/socket/PlaylistSocketHandler.js index 1fdf85a..9369bc3 100644 --- a/backend/socket/PlaylistSocketHandler.js +++ b/backend/socket/PlaylistSocketHandler.js @@ -2,9 +2,17 @@ const PlaylistService = require('../services/PlaylistService'); const ChannelService = require('../services/ChannelService'); const PlaylistUpdater = require('../services/PlaylistUpdater'); const Playlist = require('../models/Playlist'); +require('dotenv').config(); -async function handleAddPlaylist({ playlist, playlistName, mode, playlistUpdate, headers }, io, socket) { +const ADMIN_ENABLED = process.env.ADMIN_ENABLED === 'true'; + +async function handleAddPlaylist({ playlist, playlistName, mode, playlistUpdate, headers, isAdmin }, io, socket) { try { + // If admin mode is enabled but user is not admin, reject the operation + if (ADMIN_ENABLED && !isAdmin) { + return socket.emit('app-error', { message: 'Admin access required to add playlists' }); + } + const channels = await PlaylistService.addPlaylist(playlist, playlistName, mode, playlistUpdate, headers); if (channels) { @@ -23,12 +31,17 @@ async function handleAddPlaylist({ playlist, playlistName, mode, playlistUpdate, } } -async function handleUpdatePlaylist({ playlist, updatedAttributes }, io, socket) { +async function handleUpdatePlaylist({ playlist, updatedAttributes, isAdmin }, io, socket) { try { + // If admin mode is enabled but user is not admin, reject the operation + if (ADMIN_ENABLED && !isAdmin) { + return socket.emit('app-error', { message: 'Admin access required to update playlists' }); + } + if (playlist !== updatedAttributes.playlist) { // Playlist URL has changed - delete channels and fetch again - await handleDeletePlaylist(playlist, io, socket); - await handleAddPlaylist(updatedAttributes, io, socket); + await handleDeletePlaylist({ playlist, isAdmin }, io, socket); + await handleAddPlaylist({ ...updatedAttributes, isAdmin }, io, socket); return; } @@ -49,8 +62,13 @@ async function handleUpdatePlaylist({ playlist, updatedAttributes }, io, socket) } } -async function handleDeletePlaylist(playlist, io, socket) { +async function handleDeletePlaylist({ playlist, isAdmin }, io, socket) { try { + // If admin mode is enabled but user is not admin, reject the operation + if (ADMIN_ENABLED && !isAdmin) { + return socket.emit('app-error', { message: 'Admin access required to delete playlists' }); + } + const channels = await PlaylistService.deletePlaylist(playlist); channels.forEach(channel => { @@ -69,5 +87,11 @@ async function handleDeletePlaylist(playlist, io, socket) { module.exports = (io, socket) => { socket.on('add-playlist', data => handleAddPlaylist(data, io, socket)); socket.on('update-playlist', data => handleUpdatePlaylist(data, io, socket)); - socket.on('delete-playlist', playlist => handleDeletePlaylist(playlist, io, socket)); + socket.on('delete-playlist', data => { + // Handle both old format (just playlist string) and new format (object with playlist and isAdmin) + const playlist = typeof data === 'object' ? data.playlist : data; + const isAdmin = typeof data === 'object' ? data.isAdmin : false; + + handleDeletePlaylist({ playlist, isAdmin }, io, socket); + }); }; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ad62160..3eb88ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,9 @@ services: STORAGE_PATH: /streams/ # If you have problems with the playlist, set the backend url manually here #BACKEND_URL: http://localhost:5000 + # Admin mode configuration + ADMIN_ENABLED: "true" + ADMIN_PASSWORD: "your_secure_password" networks: - app-network diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0174f2d..df78d74 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import { Search, Plus, Settings, Users, Radio, Tv2, ChevronDown } from 'lucide-react'; +import { Search, Plus, Settings, Users, Radio, Tv2, ChevronDown, Shield } from 'lucide-react'; import VideoPlayer from './components/VideoPlayer'; import ChannelList from './components/ChannelList'; import Chat from './components/chat/Chat'; @@ -11,13 +11,16 @@ import SettingsModal from './components/SettingsModal'; import TvPlaylistModal from './components/TvPlaylistModal'; import { ToastProvider } from './components/notifications/ToastContext'; import ToastContainer from './components/notifications/ToastContainer'; +import { AdminProvider, useAdmin } from './components/admin/AdminContext'; +import AdminModal from './components/admin/AdminModal'; -function App() { +function AppContent() { const [channels, setChannels] = useState([]); const [selectedChannel, setSelectedChannel] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isTvPlaylistOpen, setIsTvPlaylistOpen] = useState(false); + const [isAdminModalOpen, setIsAdminModalOpen] = useState(false); const [syncEnabled, setSyncEnabled] = useState(() => { const savedValue = localStorage.getItem('syncEnabled'); return savedValue !== null ? JSON.parse(savedValue) : false; @@ -30,6 +33,8 @@ function App() { const [isPlaylistDropdownOpen, setIsPlaylistDropdownOpen] = useState(false); const [isGroupDropdownOpen, setIsGroupDropdownOpen] = useState(false); + const { isAdmin, isAdminEnabled, setIsAdminEnabled } = useAdmin(); + // Get unique playlists from channels const playlists = useMemo(() => { const uniquePlaylists = new Set(channels.map(channel => channel.playlistName).filter(playlistName => playlistName !== null)); @@ -64,6 +69,12 @@ function App() { }, [selectedPlaylist, channels]); useEffect(() => { + // Check if admin mode is enabled on the server + apiService + .request<{enabled: boolean}>('/auth/admin-status', 'GET') + .then((data) => setIsAdminEnabled(data.enabled)) + .catch((error) => console.error('Error checking admin status:', error)); + apiService .request('/channels/', 'GET') .then((data) => setChannels(data)) @@ -132,197 +143,239 @@ function App() { }, []); const handleEditChannel = (channel: Channel) => { - setEditChannel(channel); - setIsModalOpen(true); + // Only allow editing if admin mode is not enabled or user is admin + if (!isAdminEnabled || isAdmin) { + setEditChannel(channel); + setIsModalOpen(true); + } else { + setIsAdminModalOpen(true); + } }; return ( - -
-
-
-
- -

StreamHub

-
-
- setSearchQuery(e.target.value)} - className="w-full bg-gray-800 rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - -
-
- +
+
+
+
+ +

StreamHub

+ + {isAdmin && ( + + + Admin + + )} +
+
+ setSearchQuery(e.target.value)} + className="w-full bg-gray-800 rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+
+ + + + {isAdminEnabled && ( - -
-
+ )} +
+
-
-
-
-
-
-
- +
+
+
+
+
+
+ - {isPlaylistDropdownOpen && ( -
-
- {playlists.map((playlist) => ( - - ))} -
+ {isPlaylistDropdownOpen && ( +
+
+ {playlists.map((playlist) => ( + + ))}
- )} -
- - {/* Group Dropdown */} -
- - - {isGroupDropdownOpen && ( -
-
- {groups.map((group) => ( - - ))} -
-
- )} -
+
+ )}
- + + {isGroupDropdownOpen && ( +
+
+ {groups.map((group) => ( + + ))} +
+
+ )} +
+
+ + -
- - + } else { + setIsAdminModalOpen(true); + } + }} + className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors" + > + +
- +
-
- -
+ +
+ +
+
- - {isModalOpen && ( - { - setIsModalOpen(false); - setEditChannel(null); - }} - channel={editChannel} - /> - )} - - setIsSettingsOpen(false)} - syncEnabled={syncEnabled} - onSyncChange={(enabled) => { - setSyncEnabled(enabled); - localStorage.setItem('syncEnabled', JSON.stringify(enabled)); - }} - /> - - setIsTvPlaylistOpen(false)} - /> - -
+ + {isModalOpen && ( + { + setIsModalOpen(false); + setEditChannel(null); + }} + channel={editChannel} + isAdmin={isAdmin} + /> + )} + + setIsSettingsOpen(false)} + syncEnabled={syncEnabled} + onSyncChange={(enabled) => { + setSyncEnabled(enabled); + localStorage.setItem('syncEnabled', JSON.stringify(enabled)); + }} + /> + + setIsTvPlaylistOpen(false)} + isAdmin={isAdmin} + /> + + setIsAdminModalOpen(false)} + /> + + +
+ ); +} + +function App() { + return ( + + + + ); } diff --git a/frontend/src/components/ChannelList.tsx b/frontend/src/components/ChannelList.tsx index dd61ecb..c9d21d3 100644 --- a/frontend/src/components/ChannelList.tsx +++ b/frontend/src/components/ChannelList.tsx @@ -1,15 +1,25 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Channel } from '../types'; import socketService from '../services/SocketService'; +import { Lock } from 'lucide-react'; interface ChannelListProps { channels: Channel[]; selectedChannel: Channel | null; setSearchQuery: React.Dispatch>; onEditChannel: (channel: Channel) => void; + isAdmin?: boolean; + isAdminEnabled?: boolean; } -function ChannelList({ channels, selectedChannel, setSearchQuery, onEditChannel }: ChannelListProps) { +function ChannelList({ + channels, + selectedChannel, + setSearchQuery, + onEditChannel, + isAdmin = false, + isAdminEnabled = false +}: ChannelListProps) { const onSelectChannel = (channel: Channel) => { setSearchQuery(''); @@ -42,6 +52,8 @@ function ChannelList({ channels, selectedChannel, setSearchQuery, onEditChannel alt={channel.name} className="w-full h-full object-contain rounded-lg transition-transform group-hover:scale-105" /> +
+ )}

{channel.name.length > 28 ? `${channel.name.substring(0, 28)}...` : channel.name} @@ -52,4 +64,4 @@ function ChannelList({ channels, selectedChannel, setSearchQuery, onEditChannel ); } -export default ChannelList; +export default ChannelList; \ No newline at end of file diff --git a/frontend/src/components/TvPlaylistModal.tsx b/frontend/src/components/TvPlaylistModal.tsx index 5862dca..41ffbdf 100644 --- a/frontend/src/components/TvPlaylistModal.tsx +++ b/frontend/src/components/TvPlaylistModal.tsx @@ -1,15 +1,17 @@ -import { X, Copy, Tv2 } from 'lucide-react'; -import { useContext } from 'react'; +import { X, Copy, Tv2, Eye, EyeOff } from 'lucide-react'; +import { useContext, useState } from 'react'; import { ToastContext } from './notifications/ToastContext'; interface TvPlaylistModalProps { isOpen: boolean; onClose: () => void; + isAdmin?: boolean; } -function TvPlaylistModal({ isOpen, onClose }: TvPlaylistModalProps) { +function TvPlaylistModal({ isOpen, onClose, isAdmin = false }: TvPlaylistModalProps) { const { addToast } = useContext(ToastContext); const playlistUrl = `${import.meta.env.VITE_BACKEND_URL || window.location.origin}/api/channels/playlist`; + const [showHiddenInfo, setShowHiddenInfo] = useState(false); if (!isOpen) return null; @@ -66,6 +68,35 @@ function TvPlaylistModal({ isOpen, onClose }: TvPlaylistModalProps) {

Use this playlist in any other IPTV player. If you have problems, check if the base-url in the playlist is correctly pointing to the backend. If not, please set BACKEND_URL in the docker-compose.yml

+ + {isAdmin && ( +
+
+

Admin Information

+ +
+ +
+

+ {showHiddenInfo ? ( + <> + This playlist contains the actual stream URLs. You can share a link to the + application with non-admin users, and they will be able to watch the streams + without seeing the actual stream URLs. + + ) : ( + 'Click "Show sensitive info" to view additional information about the playlist.' + )} +

+
+
+ )}
diff --git a/frontend/src/components/add_channel/ChannelModal.tsx b/frontend/src/components/add_channel/ChannelModal.tsx index 6bb78b0..6f35381 100644 --- a/frontend/src/components/add_channel/ChannelModal.tsx +++ b/frontend/src/components/add_channel/ChannelModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useContext } from 'react'; -import { Plus, Trash2, X } from 'lucide-react'; +import { Plus, Trash2, X, Eye, EyeOff } from 'lucide-react'; import socketService from '../../services/SocketService'; import { CustomHeader, Channel, ChannelMode } from '../../types'; import CustomHeaderInput from './CustomHeaderInput'; @@ -9,12 +9,14 @@ import { ModeTooltipContent, Tooltip } from '../Tooltip'; interface ChannelModalProps { onClose: () => void; channel?: Channel | null; + isAdmin?: boolean; } -function ChannelModal({ onClose, channel }: ChannelModalProps) { +function ChannelModal({ onClose, channel, isAdmin = false }: ChannelModalProps) { const [type, setType] = useState<'channel' | 'playlist'>('playlist'); const [isEditMode, setIsEditMode] = useState(false); const [inputMethod, setInputMethod] = useState<'url' | 'text'>('url'); + const [showSensitiveInfo, setShowSensitiveInfo] = useState(false); const [name, setName] = useState(''); const [url, setUrl] = useState(''); @@ -100,7 +102,8 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) { url.trim(), avatar.trim() || 'https://via.placeholder.com/64', mode, - JSON.stringify(headers) + JSON.stringify(headers), + isAdmin ); } else if (type === 'playlist') { if (inputMethod === 'url' && !playlistUrl.trim()) return; @@ -111,7 +114,8 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) { playlistName.trim(), mode, playlistUpdate, - JSON.stringify(headers) + JSON.stringify(headers), + isAdmin ); } @@ -132,7 +136,7 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) { avatar: avatar.trim() || 'https://via.placeholder.com/64', mode: mode, headers: headers, - }); + }, isAdmin); } else if (type === 'playlist') { const newPlaylist = inputMethod === 'url' ? playlistUrl.trim() : playlistText.trim(); socketService.updatePlaylist(channel!.playlist, { @@ -141,7 +145,7 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) { playlistUpdate: playlistUpdate, mode: mode, headers: headers, - }); + }, isAdmin); } addToast({ @@ -156,9 +160,9 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) { const handleDelete = () => { if (channel) { if (type === 'channel') { - socketService.deleteChannel(channel.id); + socketService.deleteChannel(channel.id, isAdmin); } else if (type === 'playlist') { - socketService.deletePlaylist(channel.playlist); + socketService.deletePlaylist(channel.playlist, isAdmin); } } addToast({ @@ -169,6 +173,32 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) { onClose(); }; + // Obfuscate part of the URL to hide sensitive information + const getObfuscatedUrl = (fullUrl: string) => { + if (!fullUrl || showSensitiveInfo) return fullUrl; + + try { + const url = new URL(fullUrl); + // Hide username and password in URL if present + if (url.username || url.password) { + return fullUrl.replace(/\/\/([^:@]+:[^@]+@)/g, '//***:***@'); + } + + // Hide tokens or API keys in query params + if (url.search && (url.search.includes('token') || url.search.includes('key') || url.search.includes('password') || url.search.includes('auth'))) { + return `${url.origin}${url.pathname}?***hidden***`; + } + + return fullUrl; + } catch { + // If URL is malformed, just return a partially obfuscated string + if (fullUrl.length > 20) { + return fullUrl.substring(0, 10) + '...' + fullUrl.substring(fullUrl.length - 10); + } + return fullUrl; + } + }; + return (
@@ -221,17 +251,30 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) { />
- +
+ + {isAdmin && ( + + )} +
setUrl(e.target.value)} className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Enter stream URL" required + readOnly={!isAdmin && !showSensitiveInfo} />
@@ -330,15 +373,26 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) { M3U Text + {isAdmin && ( + + )}
setPlaylistUrl(e.target.value)} className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Enter M3U playlist URL" required={inputMethod === 'url'} + readOnly={!isAdmin && !showSensitiveInfo} />
) : ( @@ -361,15 +415,26 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) { M3U Text + {isAdmin && ( + + )}