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
This commit is contained in:
Aron Wiederkehr
2025-04-28 11:16:35 +02:00
parent b3e3870c89
commit 8d0032ad59
13 changed files with 672 additions and 242 deletions

View File

@@ -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
});
}
};

View File

@@ -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);
})
})

View File

@@ -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 });
}
});
};
};

View File

@@ -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);
});
};

View File

@@ -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

View File

@@ -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<Channel[]>([]);
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(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<Channel[]>('/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 (
<ToastProvider>
<div className="min-h-screen bg-gray-900 text-gray-100">
<div className="container mx-auto py-4">
<header className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-2">
<Radio className="w-8 h-8 text-blue-500" />
<h1 className="text-2xl font-bold">StreamHub</h1>
</div>
<div className="relative max-w-md w-full">
<input
type="text"
placeholder="Search channels..."
value={searchQuery}
onChange={(e) => 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"
/>
<Search className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" />
</div>
<div className="flex items-center space-x-4">
<Users className="w-6 h-6 text-blue-500" />
<div className="min-h-screen bg-gray-900 text-gray-100">
<div className="container mx-auto py-4">
<header className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-2">
<Radio className="w-8 h-8 text-blue-500" />
<h1 className="text-2xl font-bold">StreamHub</h1>
{isAdmin && (
<span className="ml-2 flex items-center px-2 py-1 text-xs font-medium text-green-400 bg-green-400 bg-opacity-10 rounded-full border border-green-400">
<Shield className="w-3 h-3 mr-1" />
Admin
</span>
)}
</div>
<div className="relative max-w-md w-full">
<input
type="text"
placeholder="Search channels..."
value={searchQuery}
onChange={(e) => 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"
/>
<Search className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" />
</div>
<div className="flex items-center space-x-4">
<Users className="w-6 h-6 text-blue-500" />
<button
onClick={() => setIsTvPlaylistOpen(true)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<Tv2 className="w-6 h-6 text-blue-500" />
</button>
<button
onClick={() => setIsSettingsOpen(true)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<Settings className="w-6 h-6 text-blue-500" />
</button>
{isAdminEnabled && (
<button
onClick={() => setIsTvPlaylistOpen(true)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsAdminModalOpen(true)}
className={`p-2 hover:bg-gray-800 rounded-lg transition-colors ${isAdmin ? 'text-green-500' : ''}`}
>
<Tv2 className="w-6 h-6 text-blue-500" />
<Shield className="w-6 h-6" />
</button>
<button
onClick={() => setIsSettingsOpen(true)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<Settings className="w-6 h-6 text-blue-500" />
</button>
</div>
</header>
)}
</div>
</header>
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 lg:col-span-8 space-y-4">
<div className="bg-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4">
<div className="relative">
<button
onClick={() => {
setIsPlaylistDropdownOpen(!isPlaylistDropdownOpen);
setIsGroupDropdownOpen(false);
}}
className="flex items-center space-x-2 group"
>
<div className="flex items-center space-x-2">
<Tv2 className="w-5 h-5 text-blue-500" />
<h2 className="text-xl font-semibold group-hover:text-blue-400 transition-colors">
{selectedPlaylist}
</h2>
</div>
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${isPlaylistDropdownOpen ? 'rotate-180' : ''}`} />
</button>
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 lg:col-span-8 space-y-4">
<div className="bg-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4">
<div className="relative">
<button
onClick={() => {
setIsPlaylistDropdownOpen(!isPlaylistDropdownOpen);
setIsGroupDropdownOpen(false);
}}
className="flex items-center space-x-2 group"
>
<div className="flex items-center space-x-2">
<Tv2 className="w-5 h-5 text-blue-500" />
<h2 className="text-xl font-semibold group-hover:text-blue-400 transition-colors">
{selectedPlaylist}
</h2>
</div>
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${isPlaylistDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{isPlaylistDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50 overflow-hidden">
<div className="max-h-72 overflow-y-auto scroll-container">
{playlists.map((playlist) => (
<button
key={playlist}
onClick={() => {
setSelectedPlaylist(playlist);
setSelectedGroup('Category');
setIsPlaylistDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-gray-700 ${
selectedPlaylist === playlist ? 'text-blue-400 text-base font-semibold' : 'text-gray-200'
}`}
style={{
whiteSpace: 'normal',
wordWrap: 'break-word',
overflowWrap: 'anywhere',
}}
>
{playlist}
</button>
))}
</div>
{isPlaylistDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50 overflow-hidden">
<div className="max-h-72 overflow-y-auto scroll-container">
{playlists.map((playlist) => (
<button
key={playlist}
onClick={() => {
setSelectedPlaylist(playlist);
setSelectedGroup('Category');
setIsPlaylistDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-gray-700 ${
selectedPlaylist === playlist ? 'text-blue-400 text-base font-semibold' : 'text-gray-200'
}`}
style={{
whiteSpace: 'normal',
wordWrap: 'break-word',
overflowWrap: 'anywhere',
}}
>
{playlist}
</button>
))}
</div>
)}
</div>
{/* Group Dropdown */}
<div className="relative">
<button
onClick={() => {
setIsGroupDropdownOpen(!isGroupDropdownOpen);
setIsPlaylistDropdownOpen(false);
}}
className="flex items-center space-x-2 group py-0.5 px-1.5 rounded-lg transition-all bg-white bg-opacity-10"
>
<div className="flex items-center space-x-2">
<h4 className="text-base text-gray-300 group-hover:text-blue-400 transition-colors">
{selectedGroup}
</h4>
</div>
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform duration-200 ${isGroupDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{isGroupDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50 overflow-hidden">
<div className="max-h-72 overflow-y-auto scroll-container">
{groups.map((group) => (
<button
key={group}
onClick={() => {
setSelectedGroup(group);
setIsGroupDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-gray-700 ${
selectedGroup === group ? 'text-blue-400 text-base font-semibold' : 'text-gray-200'
}`}
style={{
whiteSpace: 'normal',
wordWrap: 'break-word',
overflowWrap: 'anywhere',
}}
>
{group === 'Category' ? 'All Categories' : group}
</button>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
<button
onClick={() => {
{/* Group Dropdown */}
<div className="relative">
<button
onClick={() => {
setIsGroupDropdownOpen(!isGroupDropdownOpen);
setIsPlaylistDropdownOpen(false);
}}
className="flex items-center space-x-2 group py-0.5 px-1.5 rounded-lg transition-all bg-white bg-opacity-10"
>
<div className="flex items-center space-x-2">
<h4 className="text-base text-gray-300 group-hover:text-blue-400 transition-colors">
{selectedGroup}
</h4>
</div>
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform duration-200 ${isGroupDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{isGroupDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50 overflow-hidden">
<div className="max-h-72 overflow-y-auto scroll-container">
{groups.map((group) => (
<button
key={group}
onClick={() => {
setSelectedGroup(group);
setIsGroupDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-gray-700 ${
selectedGroup === group ? 'text-blue-400 text-base font-semibold' : 'text-gray-200'
}`}
style={{
whiteSpace: 'normal',
wordWrap: 'break-word',
overflowWrap: 'anywhere',
}}
>
{group === 'Category' ? 'All Categories' : group}
</button>
))}
</div>
</div>
)}
</div>
</div>
<button
onClick={() => {
// Only allow adding channels if admin mode is not enabled or user is admin
if (!isAdminEnabled || isAdmin) {
setIsModalOpen(true);
setIsGroupDropdownOpen(false);
setIsPlaylistDropdownOpen(false);
}}
className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5" />
</button>
</div>
<ChannelList
channels={filteredChannels}
selectedChannel={selectedChannel}
setSearchQuery={setSearchQuery}
onEditChannel={handleEditChannel}
/>
} else {
setIsAdminModalOpen(true);
}
}}
className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5" />
</button>
</div>
<VideoPlayer channel={selectedChannel} syncEnabled={syncEnabled} />
<ChannelList
channels={filteredChannels}
selectedChannel={selectedChannel}
setSearchQuery={setSearchQuery}
onEditChannel={handleEditChannel}
isAdmin={isAdmin}
isAdminEnabled={isAdminEnabled}
/>
</div>
<div className="col-span-12 lg:col-span-4">
<Chat />
</div>
<VideoPlayer channel={selectedChannel} syncEnabled={syncEnabled} />
</div>
<div className="col-span-12 lg:col-span-4">
<Chat />
</div>
</div>
{isModalOpen && (
<ChannelModal
onClose={() => {
setIsModalOpen(false);
setEditChannel(null);
}}
channel={editChannel}
/>
)}
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
syncEnabled={syncEnabled}
onSyncChange={(enabled) => {
setSyncEnabled(enabled);
localStorage.setItem('syncEnabled', JSON.stringify(enabled));
}}
/>
<TvPlaylistModal
isOpen={isTvPlaylistOpen}
onClose={() => setIsTvPlaylistOpen(false)}
/>
<ToastContainer />
</div>
{isModalOpen && (
<ChannelModal
onClose={() => {
setIsModalOpen(false);
setEditChannel(null);
}}
channel={editChannel}
isAdmin={isAdmin}
/>
)}
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
syncEnabled={syncEnabled}
onSyncChange={(enabled) => {
setSyncEnabled(enabled);
localStorage.setItem('syncEnabled', JSON.stringify(enabled));
}}
/>
<TvPlaylistModal
isOpen={isTvPlaylistOpen}
onClose={() => setIsTvPlaylistOpen(false)}
isAdmin={isAdmin}
/>
<AdminModal
isOpen={isAdminModalOpen}
onClose={() => setIsAdminModalOpen(false)}
/>
<ToastContainer />
</div>
);
}
function App() {
return (
<ToastProvider>
<AdminProvider>
<AppContent />
</AdminProvider>
</ToastProvider>
);
}

View File

@@ -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<React.SetStateAction<string>>;
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"
/>
</div>
)}
</div>
<p className="text-sm font-medium truncate text-center">
{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;

View File

@@ -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) {
<p className="text-sm text-gray-400">
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
</p>
{isAdmin && (
<div className="mt-6 border-t border-gray-700 pt-4">
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-medium">Admin Information</h3>
<button
onClick={() => setShowHiddenInfo(!showHiddenInfo)}
className="flex items-center space-x-1 text-blue-400 hover:text-blue-300"
>
{showHiddenInfo ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
<span>{showHiddenInfo ? 'Hide' : 'Show'} sensitive info</span>
</button>
</div>
<div className="bg-gray-900 rounded-lg p-4">
<p className="text-sm text-gray-300">
{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.'
)}
</p>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
<div className="bg-gray-800 rounded-lg w-full max-w-md">
@@ -221,17 +251,30 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
/>
</div>
<div>
<label htmlFor="url" className="block text-sm font-medium mb-1">
Stream URL
</label>
<div className="flex justify-between items-center mb-1">
<label htmlFor="url" className="block text-sm font-medium">
Stream URL
</label>
{isAdmin && (
<button
type="button"
onClick={() => setShowSensitiveInfo(!showSensitiveInfo)}
className="flex items-center text-xs text-blue-400 hover:text-blue-300"
>
{showSensitiveInfo ? <EyeOff className="w-3 h-3 mr-1" /> : <Eye className="w-3 h-3 mr-1" />}
{showSensitiveInfo ? 'Hide' : 'Show'} URL
</button>
)}
</div>
<input
type="url"
id="url"
value={url}
value={isAdmin ? url : getObfuscatedUrl(url)}
onChange={(e) => 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}
/>
</div>
<div>
@@ -330,15 +373,26 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
M3U Text
</button>
</label>
{isAdmin && (
<button
type="button"
onClick={() => setShowSensitiveInfo(!showSensitiveInfo)}
className="flex items-center text-xs text-blue-400 hover:text-blue-300"
>
{showSensitiveInfo ? <EyeOff className="w-3 h-3 mr-1" /> : <Eye className="w-3 h-3 mr-1" />}
{showSensitiveInfo ? 'Hide' : 'Show'} URL
</button>
)}
</div>
<input
type="url"
id="playlistUrl"
value={playlistUrl}
value={isAdmin ? playlistUrl : getObfuscatedUrl(playlistUrl)}
onChange={(e) => 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}
/>
</div>
) : (
@@ -361,15 +415,26 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
M3U Text
</button>
</label>
{isAdmin && (
<button
type="button"
onClick={() => setShowSensitiveInfo(!showSensitiveInfo)}
className="flex items-center text-xs text-blue-400 hover:text-blue-300"
>
{showSensitiveInfo ? <EyeOff className="w-3 h-3 mr-1" /> : <Eye className="w-3 h-3 mr-1" />}
{showSensitiveInfo ? 'Hide' : 'Show'} content
</button>
)}
</div>
<textarea
id="playlistText"
value={playlistText}
value={isAdmin || showSensitiveInfo ? playlistText : "#EXTM3U\n# Content hidden for privacy\n# Login as admin to view or edit"}
onChange={(e) => setPlaylistText(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 min-h-[200px] scroll-container overflow-y-auto"
placeholder="#EXTM3U..."
required={inputMethod === 'text'}
style={{ resize: 'none' }}
readOnly={!isAdmin && !showSensitiveInfo}
/>
</div>
)}
@@ -443,30 +508,38 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
<label className="block text-sm font-medium">
Custom Headers
</label>
<button
type="button"
onClick={addHeader}
className="flex items-center space-x-1 text-sm text-blue-400 hover:text-blue-300"
>
<Plus className="w-4 h-4" />
<span>Add Header</span>
</button>
{isAdmin && (
<button
type="button"
onClick={addHeader}
className="flex items-center space-x-1 text-sm text-blue-400 hover:text-blue-300"
>
<Plus className="w-4 h-4" />
<span>Add Header</span>
</button>
)}
</div>
<div className="space-y-2">
{headers && headers.map((header, index) => (
<div key={index} className="flex items-center space-x-2">
<CustomHeaderInput
header={header}
header={{
key: header.key,
value: isAdmin || showSensitiveInfo ? header.value : "***hidden***"
}}
onKeyChange={(value) => updateHeader(index, 'key', value)}
onValueChange={(value) => updateHeader(index, 'value', value)}
readOnly={!isAdmin && !showSensitiveInfo}
/>
<button
type="button"
onClick={() => removeHeader(index)}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
{isAdmin && (
<button
type="button"
onClick={() => removeHeader(index)}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
@@ -474,7 +547,7 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
)}
<div className="flex justify-end space-x-3">
{isEditMode && (
{isEditMode && isAdmin && (
<button
type="button"
onClick={handleDelete}
@@ -490,12 +563,14 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
{isEditMode ? 'Update' : 'Add'}
</button>
{isAdmin && (
<button
type="submit"
className="px-4 py-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
{isEditMode ? 'Update' : 'Add'}
</button>
)}
</div>
</form>
</div>

View File

@@ -4,9 +4,10 @@ interface CustomHeaderInputProps {
header: CustomHeader;
onKeyChange: (value: string) => void;
onValueChange: (value: string) => void;
readOnly?: boolean;
}
function CustomHeaderInput({ header, onKeyChange, onValueChange }: CustomHeaderInputProps) {
function CustomHeaderInput({ header, onKeyChange, onValueChange, readOnly = false }: CustomHeaderInputProps) {
return (
<div className="flex-1 grid grid-cols-2 gap-2">
<input
@@ -14,14 +15,16 @@ function CustomHeaderInput({ header, onKeyChange, onValueChange }: CustomHeaderI
value={header.key}
onChange={(e) => onKeyChange(e.target.value)}
placeholder="Header name"
className="bg-gray-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={`bg-gray-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none ${!readOnly ? 'focus:ring-2 focus:ring-blue-500' : ''}`}
readOnly={readOnly}
/>
<input
type="text"
value={header.value}
onChange={(e) => onValueChange(e.target.value)}
placeholder="Header value"
className="bg-gray-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className={`bg-gray-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none ${!readOnly ? 'focus:ring-2 focus:ring-blue-500' : ''}`}
readOnly={readOnly}
/>
</div>
);

View File

@@ -0,0 +1,32 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface AdminContextType {
isAdmin: boolean;
setIsAdmin: (value: boolean) => void;
isAdminEnabled: boolean;
setIsAdminEnabled: (value: boolean) => void;
}
const AdminContext = createContext<AdminContextType>({
isAdmin: false,
setIsAdmin: () => {},
isAdminEnabled: false,
setIsAdminEnabled: () => {},
});
export const useAdmin = () => useContext(AdminContext);
interface AdminProviderProps {
children: ReactNode;
}
export const AdminProvider: React.FC<AdminProviderProps> = ({ children }) => {
const [isAdmin, setIsAdmin] = useState(false);
const [isAdminEnabled, setIsAdminEnabled] = useState(false);
return (
<AdminContext.Provider value={{ isAdmin, setIsAdmin, isAdminEnabled, setIsAdminEnabled }}>
{children}
</AdminContext.Provider>
);
};

View File

@@ -0,0 +1,124 @@
import React, { useState, useContext } from 'react';
import { X, Shield, ShieldOff } from 'lucide-react';
import { ToastContext } from '../notifications/ToastContext';
import { useAdmin } from './AdminContext';
import apiService from '../../services/ApiService';
interface AdminModalProps {
isOpen: boolean;
onClose: () => void;
}
function AdminModal({ isOpen, onClose }: AdminModalProps) {
const [password, setPassword] = useState('');
const { isAdmin, setIsAdmin } = useAdmin();
const { addToast } = useContext(ToastContext);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await apiService.request<{success: boolean}>('/auth/admin-login', 'POST', undefined, {
password
});
if (response.success) {
setIsAdmin(true);
addToast({
type: 'success',
title: 'Admin mode enabled',
duration: 3000,
});
onClose();
} else {
addToast({
type: 'error',
title: 'Invalid password',
duration: 3000,
});
}
} catch (error) {
addToast({
type: 'error',
title: 'Authentication failed',
message: 'Please try again',
duration: 3000,
});
}
};
const handleLogout = () => {
setIsAdmin(false);
addToast({
type: 'info',
title: 'Admin mode disabled',
duration: 3000,
});
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 rounded-lg w-full max-w-md">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<div className="flex items-center space-x-2">
{isAdmin ? (
<Shield className="w-5 h-5 text-green-500" />
) : (
<ShieldOff className="w-5 h-5 text-blue-500" />
)}
<h2 className="text-xl font-semibold">Admin Mode</h2>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-gray-700 rounded-full transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
{isAdmin ? (
<div className="space-y-4">
<p className="text-green-500">You are currently in admin mode.</p>
<button
onClick={handleLogout}
className="w-full p-2 bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
Logout from Admin Mode
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="adminPassword" className="block text-sm font-medium mb-1">
Admin Password
</label>
<input
type="password"
id="adminPassword"
value={password}
onChange={(e) => setPassword(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 admin password"
required
/>
</div>
<button
type="submit"
className="w-full p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
Login
</button>
</form>
)}
</div>
</div>
</div>
);
}
export default AdminModal;

View File

@@ -70,10 +70,10 @@ class SocketService {
}
// Add channel
addChannel(name: string, url: string, avatar: string, mode: ChannelMode, headersJson: string) {
addChannel(name: string, url: string, avatar: string, mode: ChannelMode, headersJson: string, isAdmin: boolean = false) {
if (!this.socket) throw new Error('Socket is not connected.');
this.socket.emit('add-channel', { name, url, avatar, mode, headersJson });
this.socket.emit('add-channel', { name, url, avatar, mode, headersJson, isAdmin });
}
// Set current channel
@@ -84,38 +84,38 @@ class SocketService {
}
// Delete channel
deleteChannel(id: number) {
deleteChannel(id: number, isAdmin: boolean = false) {
if (!this.socket) throw new Error('Socket is not connected.');
this.socket.emit('delete-channel', id);
this.socket.emit('delete-channel', { id, isAdmin });
}
// Update channel
updateChannel(id: number, updatedAttributes: any) {
updateChannel(id: number, updatedAttributes: any, isAdmin: boolean = false) {
if (!this.socket) throw new Error('Socket is not connected.');
this.socket.emit('update-channel', { id, updatedAttributes });
this.socket.emit('update-channel', { id, updatedAttributes, isAdmin });
}
// Add playlist
addPlaylist(playlist: string, playlistName: string, mode: ChannelMode, playlistUpdate: boolean, headers: string) {
addPlaylist(playlist: string, playlistName: string, mode: ChannelMode, playlistUpdate: boolean, headers: string, isAdmin: boolean = false) {
if (!this.socket) throw new Error('Socket is not connected.');
this.socket.emit('add-playlist', { playlist, playlistName, mode, playlistUpdate, headers });
this.socket.emit('add-playlist', { playlist, playlistName, mode, playlistUpdate, headers, isAdmin });
}
// Update playlist
updatePlaylist(playlist: string, updatedAttributes: any) {
updatePlaylist(playlist: string, updatedAttributes: any, isAdmin: boolean = false) {
if (!this.socket) throw new Error('Socket is not connected.');
this.socket.emit('update-playlist', { playlist, updatedAttributes });
this.socket.emit('update-playlist', { playlist, updatedAttributes, isAdmin });
}
// Delete playlist
deletePlaylist(playlist: string) {
deletePlaylist(playlist: string, isAdmin: boolean = false) {
if (!this.socket) throw new Error('Socket is not connected.');
this.socket.emit('delete-playlist', playlist);
this.socket.emit('delete-playlist', { playlist, isAdmin });
}
}