feat: introduce CHANNEL_SELECTION_REQUIRES_ADMIN option

This commit is contained in:
antebrl
2025-10-07 23:09:43 +02:00
parent b5c0769654
commit 4fe017a15c
11 changed files with 53 additions and 39 deletions

View File

@@ -30,6 +30,7 @@ module.exports = {
checkAdminStatus(req, res) { checkAdminStatus(req, res) {
res.json({ res.json({
enabled: authService.isAdminEnabled(), enabled: authService.isAdminEnabled(),
channelSelectionRequiresAdmin: authService.channelSelectionRequiresAdmin(),
}); });
}, },

View File

@@ -10,7 +10,6 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"child_process": "^1.0.2", "child_process": "^1.0.2",
"cookie-parser": "^1.4.6",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "express": "^4.21.1",
@@ -232,28 +231,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",

View File

@@ -20,7 +20,6 @@
"homepage": "https://github.com/antebrl/iptv-restream#readme", "homepage": "https://github.com/antebrl/iptv-restream#readme",
"dependencies": { "dependencies": {
"child_process": "^1.0.2", "child_process": "^1.0.2",
"cookie-parser": "^1.4.6",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "express": "^4.21.1",

View File

@@ -1,7 +1,6 @@
const express = require('express'); const express = require('express');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const { Server } = require('socket.io'); const { Server } = require('socket.io');
const cookieParser = require('cookie-parser');
const ChatSocketHandler = require('./socket/ChatSocketHandler'); const ChatSocketHandler = require('./socket/ChatSocketHandler');
const ChannelSocketHandler = require('./socket/ChannelSocketHandler'); const ChannelSocketHandler = require('./socket/ChannelSocketHandler');
@@ -20,7 +19,6 @@ dotenv.config();
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use(cookieParser());
// CORS middleware // CORS middleware
app.use((req, res, next) => { app.use((req, res, next) => {

View File

@@ -8,6 +8,8 @@ require("dotenv").config();
class AuthService { class AuthService {
constructor() { constructor() {
this.ADMIN_ENABLED = process.env.ADMIN_ENABLED === "true"; this.ADMIN_ENABLED = process.env.ADMIN_ENABLED === "true";
this.CHANNEL_SELECTION_REQUIRES_ADMIN =
process.env.CHANNEL_SELECTION_REQUIRES_ADMIN === "true";
this.ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; this.ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
this.JWT_EXPIRY = process.env.JWT_EXPIRY || "24h"; this.JWT_EXPIRY = process.env.JWT_EXPIRY || "24h";
@@ -28,6 +30,13 @@ class AuthService {
.update(this.ADMIN_PASSWORD || "") .update(this.ADMIN_PASSWORD || "")
.digest("hex"); .digest("hex");
} }
/**
* Check if channel selection needs admin
* @returns {boolean}
*/
channelSelectionRequiresAdmin() {
return this.CHANNEL_SELECTION_REQUIRES_ADMIN && this.ADMIN_ENABLED;
}
/** /**
* Generate a JWT token for an admin user * Generate a JWT token for an admin user

View File

@@ -28,6 +28,15 @@ module.exports = (io, socket) => {
socket.on("set-current-channel", async (id) => { socket.on("set-current-channel", async (id) => {
try { try {
if (
authService.isAdminEnabled() &&
authService.channelSelectionRequiresAdmin() &&
!socket.user?.isAdmin
) {
return socket.emit("app-error", {
message: "Admin access required to switch channel",
});
}
const nextChannel = await ChannelService.setCurrentChannel(id); const nextChannel = await ChannelService.setCurrentChannel(id);
io.emit("channel-selected", nextChannel); // Broadcast to all clients io.emit("channel-selected", nextChannel); // Broadcast to all clients
} catch (err) { } catch (err) {

View File

@@ -5,8 +5,6 @@ const Playlist = require("../models/Playlist");
const authService = require("../services/auth/AuthService"); const authService = require("../services/auth/AuthService");
require("dotenv").config(); require("dotenv").config();
const ADMIN_ENABLED = process.env.ADMIN_ENABLED === "true";
async function handleAddPlaylist( async function handleAddPlaylist(
{ playlist, playlistName, mode, playlistUpdate, headers }, { playlist, playlistName, mode, playlistUpdate, headers },
io, io,

View File

@@ -30,9 +30,11 @@ services:
STORAGE_PATH: /streams/ STORAGE_PATH: /streams/
# If you have problems with the playlist, set the backend url manually here # If you have problems with the playlist, set the backend url manually here
#BACKEND_URL: http://localhost:5000 #BACKEND_URL: http://localhost:5000
# Admin mode configuration # Admin mode configuration
ADMIN_ENABLED: "true" # ADMIN_ENABLED: "true"
ADMIN_PASSWORD: "your_secure_password" # ADMIN_PASSWORD: "your_secure_password"
# CHANNEL_SELECTION_REQUIRES_ADMIN: "true"
networks: networks:
- app-network - app-network

View File

@@ -34,7 +34,7 @@ function AppContent() {
const [isPlaylistDropdownOpen, setIsPlaylistDropdownOpen] = useState(false); const [isPlaylistDropdownOpen, setIsPlaylistDropdownOpen] = useState(false);
const [isGroupDropdownOpen, setIsGroupDropdownOpen] = useState(false); const [isGroupDropdownOpen, setIsGroupDropdownOpen] = useState(false);
const { isAdmin, isAdminEnabled, setIsAdminEnabled } = useAdmin(); const { isAdmin, isAdminEnabled, setIsAdminEnabled, channelSelectRequiresAdmin, setChannelSelectRequiresAdmin } = useAdmin();
const { addToast } = useContext(ToastContext); const { addToast } = useContext(ToastContext);
// Get unique playlists from channels // Get unique playlists from channels
@@ -73,8 +73,11 @@ function AppContent() {
useEffect(() => { useEffect(() => {
// Check if admin mode is enabled on the server // Check if admin mode is enabled on the server
apiService apiService
.request<{ enabled: boolean }>('/auth/admin-status', 'GET') .request<{ enabled: boolean; channelSelectionRequiresAdmin: boolean }>('/auth/admin-status', 'GET')
.then((data) => setIsAdminEnabled(data.enabled)) .then((data) => {
setIsAdminEnabled(data.enabled);
setChannelSelectRequiresAdmin(data.channelSelectionRequiresAdmin);
})
.catch((error) => console.error('Error checking admin status:', error)); .catch((error) => console.error('Error checking admin status:', error));
apiService apiService
@@ -345,6 +348,13 @@ function AppContent() {
selectedChannel={selectedChannel} selectedChannel={selectedChannel}
setSearchQuery={setSearchQuery} setSearchQuery={setSearchQuery}
onEditChannel={handleEditChannel} onEditChannel={handleEditChannel}
onChannelSelectCheckPermission={() => {
if (isAdminEnabled && channelSelectRequiresAdmin && !isAdmin) {
setIsAdminModalOpen(true);
return false;
}
return true;
}}
/> />
</div> </div>

View File

@@ -7,6 +7,7 @@ interface ChannelListProps {
selectedChannel: Channel | null; selectedChannel: Channel | null;
setSearchQuery: React.Dispatch<React.SetStateAction<string>>; setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
onEditChannel: (channel: Channel) => void; onEditChannel: (channel: Channel) => void;
onChannelSelectCheckPermission: () => boolean;
} }
function ChannelList({ function ChannelList({
@@ -14,10 +15,13 @@ function ChannelList({
selectedChannel, selectedChannel,
setSearchQuery, setSearchQuery,
onEditChannel, onEditChannel,
onChannelSelectCheckPermission,
}: ChannelListProps) { }: ChannelListProps) {
const onSelectChannel = (channel: Channel) => { const onSelectChannel = (channel: Channel) => {
setSearchQuery(""); setSearchQuery("");
if (channel.id === selectedChannel?.id) return; if (channel.id === selectedChannel?.id) return;
if (!onChannelSelectCheckPermission()) return;
socketService.setCurrentChannel(channel.id); socketService.setCurrentChannel(channel.id);
}; };

View File

@@ -9,10 +9,12 @@ import { jwtDecode } from 'jwt-decode';
import socketService from '../../services/SocketService'; import socketService from '../../services/SocketService';
interface AdminContextType { interface AdminContextType {
isAdmin: boolean; isAdmin: boolean | null;
setIsAdmin: (value: boolean) => void; setIsAdmin: (value: boolean) => void;
isAdminEnabled: boolean; isAdminEnabled: boolean;
setIsAdminEnabled: (value: boolean) => void; setIsAdminEnabled: (value: boolean) => void;
channelSelectRequiresAdmin: boolean;
setChannelSelectRequiresAdmin: (value: boolean) => void;
adminToken: string | null; adminToken: string | null;
} }
@@ -21,6 +23,8 @@ const AdminContext = createContext<AdminContextType>({
setIsAdmin: () => {}, setIsAdmin: () => {},
isAdminEnabled: false, isAdminEnabled: false,
setIsAdminEnabled: () => {}, setIsAdminEnabled: () => {},
channelSelectRequiresAdmin: false,
setChannelSelectRequiresAdmin: () => {},
adminToken: null, adminToken: null,
}); });
@@ -42,21 +46,22 @@ const isTokenValid = (token: string): boolean => {
}; };
export const AdminProvider: React.FC<AdminProviderProps> = ({ children }) => { export const AdminProvider: React.FC<AdminProviderProps> = ({ children }) => {
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [isAdminEnabled, setIsAdminEnabled] = useState(false); const [isAdminEnabled, setIsAdminEnabled] = useState(false);
const [channelSelectRequiresAdmin, setChannelSelectRequiresAdmin] = useState(false);
const [adminToken, setAdminToken] = useState<string | null>(null); const [adminToken, setAdminToken] = useState<string | null>(null);
// Effect to handle token changes // Effect to handle token changes
useEffect(() => { useEffect(() => {
// When admin status changes, update socket connection // When admin status changes, update socket connection
if (isAdmin) { if (isAdmin === true) {
// Small delay to ensure token is saved before reconnecting // Small delay to ensure token is saved before reconnecting
setTimeout(() => { setTimeout(() => {
socketService.updateAuthToken(); socketService.updateAuthToken();
}, 100); }, 100);
} else { } else if (isAdmin === false) {
// Reset token and reconnect // Reset token and reconnect
localStorage.removeItem('admin_token'); localStorage.removeItem("admin_token");
setAdminToken(null); setAdminToken(null);
socketService.updateAuthToken(); socketService.updateAuthToken();
} }
@@ -72,7 +77,7 @@ export const AdminProvider: React.FC<AdminProviderProps> = ({ children }) => {
setAdminToken(token); setAdminToken(token);
} else if (token) { } else if (token) {
// Clear invalid token // Clear invalid token
localStorage.removeItem('admin_token'); setIsAdmin(false);
} }
}, []); }, []);
@@ -83,6 +88,8 @@ export const AdminProvider: React.FC<AdminProviderProps> = ({ children }) => {
setIsAdmin, setIsAdmin,
isAdminEnabled, isAdminEnabled,
setIsAdminEnabled, setIsAdminEnabled,
channelSelectRequiresAdmin,
setChannelSelectRequiresAdmin,
adminToken, adminToken,
}} }}
> >