Merge pull request #65 from aronjanosch/simple-admin-mode

Admin mode for limiting access to channel management
This commit is contained in:
Ante Brähler
2025-10-07 23:24:41 +02:00
committed by GitHub
22 changed files with 1316 additions and 372 deletions

3
.gitignore vendored
View File

@@ -43,3 +43,6 @@ Thumbs.db
*.zip
*.tar.gz
*.rar
.devcontainer/

View File

@@ -1,5 +1,4 @@
> [!NOTE]
> Thank you for all the amazing feedback and ideas! One of the most requested features has been a **hosted solution** for non-technical users.
>
> #### NEW VERSION
> 🎉 I'm currently working on a fully hosted version no setup required and **completely FREE**!
@@ -15,16 +14,6 @@
# IPTV StreamHub
A simple IPTV `restream` and `synchronization` (watch2gether) application with `web` frontend. Share your iptv playlist and watch it together with your friends.
Actively in devlopment and open for your ideas! <br>
Easily test it locally using Docker Compose!
> [!IMPORTANT]
> If you're using an **Xtream Codes** playlist (format: `/get.php?username=xxx&password=xxx&type=xxx&output=xxx`), try the following options:
> - Use **proxy mode** with HLS output: Use `&type=m3u_plus&output=hls` in your playlist URL.
> - Use **restream mode** with MPEG-TS output: Use `&type=m3u_plus&output=ts` to your playlist URL.
>
> If your playlist is a plain HTTP link or has CORS issues, you must use **proxy** or **restream mode** to ensure compatibility in the web.
## 💡Use Cases
- [x] IPTV Web player supporting multiple playlists at once.
- [x] Connect with **multiple Devices** to 1 IPTV Stream, if your provider limits current streaming devices (restream mode).
@@ -58,8 +47,16 @@ Open http://localhost
> [!IMPORTANT]
> If a channel/playlist won't work, please try with `proxy` or `restream` mode. This fixes most of the problems! See also [Channel Mode](#channel-mode).
>
> If you're using an **Xtream Codes** playlist (format: `/get.php?username=xxx&password=xxx&type=xxx&output=xxx`), try the following options:
> - Use **proxy mode** with HLS output: Use `&type=m3u_plus&output=hls` in your playlist URL.
> - Use **restream mode** with MPEG-TS output: Use `&type=m3u_plus&output=ts` to your playlist URL.
>
> If your playlist is a plain HTTP link or has CORS issues, you must use **proxy** or **restream mode** to ensure compatibility in the web.
There is also [documentation for advanced deployment](/deployment/README.md):
There is also [documentation for ADVANCED DEPLOYMENT](/deployment/README.md):
- Configuration options (Admin mode).
- Deploy from container registry and without cloning and building.
- Deploy together with nginx proxy manager for automatic ssl handling.
@@ -131,6 +128,10 @@ Is it possible to run components seperately, if I only need the frontend OR back
> If you only need the **synchronization** functionality, you may only run the [frontend](/frontend/README.md).
>
> Be aware, that this'll require additional configuration/adaption and won't be officially supported. It is recommended to [run the whole project as once](#run-with-docker-preferred).
---
Is there a option to limit access of channel management?
> Yes, you can enable [**Admin Mode**](/deployment/README.md#admin-mode) in the configuration to restrict channel management to authenticated administrators only.
## Contribute & Contact
Feel free to open discussions and issues for any type of requests. Don't hesitate to contact me, if you have any problems with the setup.

View File

@@ -0,0 +1,58 @@
require("dotenv").config();
const authService = require("../services/auth/AuthService");
module.exports = {
adminLogin(req, res) {
if (!authService.isAdminEnabled()) {
return res.status(403).json({
success: false,
message: "Admin mode is disabled on this server",
});
}
const { password } = req.body;
if (authService.verifyAdminPassword(password)) {
const token = authService.generateAdminToken();
return res.json({
success: true,
token,
});
} else {
return res.status(401).json({
success: false,
message: "Invalid password",
});
}
},
checkAdminStatus(req, res) {
res.json({
enabled: authService.isAdminEnabled(),
channelSelectionRequiresAdmin: authService.channelSelectionRequiresAdmin(),
});
},
verifyToken(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
return res.status(401).json({
success: false,
message: "Access denied. No token provided.",
});
}
const decoded = authService.verifyToken(token);
if (!decoded) {
return res.status(401).json({
success: false,
message: "Invalid token.",
});
}
req.user = decoded;
next();
},
};

View File

@@ -10,9 +10,11 @@
"license": "ISC",
"dependencies": {
"child_process": "^1.0.2",
"crypto": "^1.0.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"iptv-playlist-parser": "^0.13.0",
"jsonwebtoken": "^9.0.2",
"node-cron": "^3.0.3",
"request": "^2.88.2",
"socket.io": "^4.8.1"
@@ -149,6 +151,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -245,6 +253,12 @@
"node": ">= 0.10"
}
},
"node_modules/crypto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz",
"integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
"deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in."
},
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
@@ -325,6 +339,15 @@
"safer-buffer": "^2.1.0"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -788,6 +811,34 @@
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jsprim": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
@@ -802,6 +853,69 @@
"node": ">=0.6.0"
}
},
"node_modules/jwa": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -1074,6 +1188,18 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",

View File

@@ -20,9 +20,11 @@
"homepage": "https://github.com/antebrl/iptv-restream#readme",
"dependencies": {
"child_process": "^1.0.2",
"crypto": "^1.0.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"iptv-playlist-parser": "^0.13.0",
"jsonwebtoken": "^9.0.2",
"node-cron": "^3.0.3",
"request": "^2.88.2",
"socket.io": "^4.8.1"

View File

@@ -4,13 +4,15 @@ const { Server } = require('socket.io');
const ChatSocketHandler = require('./socket/ChatSocketHandler');
const ChannelSocketHandler = require('./socket/ChannelSocketHandler');
const PlaylistSocketHandler = require('./socket/PlaylistSocketHandler');
const socketAuthMiddleware = require('./socket/middleware/jwt');
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');
const PlaylistUpdater = require('./services/PlaylistUpdater');
dotenv.config();
@@ -18,15 +20,35 @@ 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, Authorization');
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);
apiRouter.delete('/clear', channelController.clearChannels);
apiRouter.delete('/clear', authController.verifyToken, channelController.clearChannels);
apiRouter.get('/playlist', centralChannelController.playlist);
apiRouter.get('/:channelId', channelController.getChannel);
apiRouter.delete('/:channelId', channelController.deleteChannel);
apiRouter.put('/:channelId', channelController.updateChannel);
apiRouter.post('/', channelController.addChannel);
// Protected routes
apiRouter.delete('/:channelId', authController.verifyToken, channelController.deleteChannel);
apiRouter.put('/:channelId', authController.verifyToken, channelController.updateChannel);
apiRouter.post('/', authController.verifyToken, channelController.addChannel);
app.use('/api/channels', apiRouter);
const proxyRouter = express.Router();
@@ -48,8 +70,18 @@ const server = app.listen(PORT, async () => {
});
// Web Sockets
const io = new Server(server);
// Web Sockets with explicit CORS configuration
const io = new Server(server, {
cors: {
origin: "*", // Allow any origin in development
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Authorization", "Content-Type"],
credentials: true,
},
});
// Add JWT authentication middleware to socket.io
io.use(socketAuthMiddleware);
const connectedUsers = {};
@@ -68,6 +100,5 @@ io.on('connection', socket => {
ChannelSocketHandler(io, socket);
PlaylistSocketHandler(io, socket);
ChatSocketHandler(io, socket);
})

View File

@@ -0,0 +1,82 @@
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
require("dotenv").config();
/**
* Service for handling JWT authentication
*/
class AuthService {
constructor() {
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.JWT_EXPIRY = process.env.JWT_EXPIRY || "24h";
// Validate admin password if admin mode is enabled
if (
this.ADMIN_ENABLED &&
(!this.ADMIN_PASSWORD || this.ADMIN_PASSWORD.length < 12)
) {
throw new Error(
"ADMIN_PASSWORD must be set and at least 12 characters long for security."
);
}
// Generate a secure JWT secret from the admin password
// or use a random value if admin mode is disabled
this.JWT_SECRET = crypto
.createHash("sha256")
.update(this.ADMIN_PASSWORD || "")
.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
* @returns {string} JWT token
*/
generateAdminToken() {
return jwt.sign({ isAdmin: true }, this.JWT_SECRET, {
expiresIn: this.JWT_EXPIRY,
});
}
/**
* Verify a JWT token
* @param {string} token - The JWT token to verify
* @returns {Object|null} Decoded token payload or null if invalid
*/
verifyToken(token) {
try {
return jwt.verify(token, this.JWT_SECRET);
} catch (error) {
return null;
}
}
/**
* Check if admin mode is enabled
* @returns {boolean} True if admin mode is enabled
*/
isAdminEnabled() {
return this.ADMIN_ENABLED;
}
/**
* Verify admin password
* @param {string} password - Password to verify
* @returns {boolean} True if password matches
*/
verifyAdminPassword(password) {
return this.ADMIN_PASSWORD === password;
}
}
module.exports = new AuthService();

View File

@@ -1,46 +1,86 @@
const ChannelService = require('../services/ChannelService');
const ChannelService = require("../services/ChannelService");
const authService = require("../services/auth/AuthService");
module.exports = (io, socket) => {
// Check if admin mode is required for channel modifications
socket.on("add-channel", ({ name, url, avatar, mode, headersJson }) => {
try {
// Check if user is authenticated as admin from the socket middleware
if (authService.isAdminEnabled() && !socket.user?.isAdmin) {
return socket.emit("app-error", {
message: "Admin access required to add channels",
});
}
socket.on('add-channel', ({ name, url, avatar, mode, headersJson }) => {
try {
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
} catch (err) {
socket.emit('app-error', { message: err.message });
}
});
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
} catch (err) {
socket.emit("app-error", { message: err.message });
}
});
socket.on('set-current-channel', async (id) => {
try {
const nextChannel = await ChannelService.setCurrentChannel(id);
io.emit('channel-selected', nextChannel); // Broadcast to all clients
} catch (err) {
console.error(err);
socket.emit('app-error', { message: err.message });
}
});
socket.on("set-current-channel", async (id) => {
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);
io.emit("channel-selected", nextChannel); // Broadcast to all clients
} catch (err) {
console.error(err);
socket.emit("app-error", { message: err.message });
}
});
socket.on('delete-channel', async (id) => {
try {
const lastChannel = ChannelService.getCurrentChannel();
const current = await ChannelService.deleteChannel(id);
io.emit('channel-deleted', id); // Broadcast to all clients
if(lastChannel.id != current.id) io.emit('channel-selected', current);
} catch (err) {
console.error(err);
socket.emit('app-error', { message: err.message });
}
});
socket.on("delete-channel", async (id) => {
try {
// Check if user is authenticated as admin from the socket middleware
if (authService.isAdminEnabled() && !socket.user?.isAdmin) {
return socket.emit("app-error", {
message: "Admin access required to delete channels",
});
}
socket.on('update-channel', async ({ id, updatedAttributes }) => {
try {
const updatedChannel = await ChannelService.updateChannel(id, updatedAttributes);
io.emit('channel-updated', updatedChannel); // Broadcast to all clients
} catch (err) {
console.error(err);
socket.emit('app-error', { message: err.message });
}
});
const lastChannel = ChannelService.getCurrentChannel();
const current = await ChannelService.deleteChannel(id);
io.emit("channel-deleted", id); // Broadcast to all clients
if (lastChannel.id != current.id) io.emit("channel-selected", current);
} catch (err) {
console.error(err);
socket.emit("app-error", { message: err.message });
}
});
socket.on("update-channel", async ({ id, updatedAttributes }) => {
try {
// Check if user is authenticated as admin from the socket middleware
if (authService.isAdminEnabled() && !socket.user?.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) {
console.error(err);
socket.emit("app-error", { message: err.message });
}
});
};

View File

@@ -1,73 +1,124 @@
const PlaylistService = require('../services/PlaylistService');
const ChannelService = require('../services/ChannelService');
const PlaylistUpdater = require('../services/PlaylistUpdater');
const Playlist = require('../models/Playlist');
const PlaylistService = require("../services/PlaylistService");
const ChannelService = require("../services/ChannelService");
const PlaylistUpdater = require("../services/PlaylistUpdater");
const Playlist = require("../models/Playlist");
const authService = require("../services/auth/AuthService");
require("dotenv").config();
async function handleAddPlaylist({ playlist, playlistName, mode, playlistUpdate, headers }, io, socket) {
try {
const channels = await PlaylistService.addPlaylist(playlist, playlistName, mode, playlistUpdate, headers);
if (channels) {
channels.forEach(channel => {
io.emit('channel-added', channel);
});
}
if(playlistUpdate) {
PlaylistUpdater.register(new Playlist(playlist, playlistName, mode, playlistUpdate, headers));
}
} catch (err) {
console.error(err);
socket.emit('app-error', { message: err.message });
async function handleAddPlaylist(
{ playlist, playlistName, mode, playlistUpdate, headers },
io,
socket
) {
try {
// Check if user is authenticated as admin from the socket middleware
if (authService.isAdminEnabled() && !socket.user?.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) {
channels.forEach((channel) => {
io.emit("channel-added", channel);
});
}
if (playlistUpdate) {
PlaylistUpdater.register(
new Playlist(playlist, playlistName, mode, playlistUpdate, headers)
);
}
} catch (err) {
console.error(err);
socket.emit("app-error", { message: err.message });
}
}
async function handleUpdatePlaylist({ playlist, updatedAttributes }, io, socket) {
try {
if (playlist !== updatedAttributes.playlist) {
// Playlist URL has changed - delete channels and fetch again
await handleDeletePlaylist(playlist, io, socket);
await handleAddPlaylist(updatedAttributes, io, socket);
return;
}
const channels = await PlaylistService.updatePlaylist(playlist, updatedAttributes);
channels.forEach(channel => {
io.emit('channel-updated', channel);
});
PlaylistUpdater.delete(playlist);
if(updatedAttributes.playlistUpdate) {
PlaylistUpdater.register(new Playlist(playlist, updatedAttributes.playlistName, updatedAttributes.mode, updatedAttributes.playlistUpdate, updatedAttributes.headers));
}
} catch (err) {
console.error(err);
socket.emit('app-error', { message: err.message });
async function handleUpdatePlaylist(
{ playlist, updatedAttributes },
io,
socket
) {
try {
// Check if user is authenticated as admin from the socket middleware
if (authService.isAdminEnabled() && !socket.user?.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);
return;
}
const channels = await PlaylistService.updatePlaylist(
playlist,
updatedAttributes
);
channels.forEach((channel) => {
io.emit("channel-updated", channel);
});
PlaylistUpdater.delete(playlist);
if (updatedAttributes.playlistUpdate) {
PlaylistUpdater.register(
new Playlist(
playlist,
updatedAttributes.playlistName,
updatedAttributes.mode,
updatedAttributes.playlistUpdate,
updatedAttributes.headers
)
);
}
} catch (err) {
console.error(err);
socket.emit("app-error", { message: err.message });
}
}
async function handleDeletePlaylist(playlist, io, socket) {
try {
const channels = await PlaylistService.deletePlaylist(playlist);
channels.forEach(channel => {
io.emit('channel-deleted', channel.id);
});
io.emit('channel-selected', ChannelService.getCurrentChannel());
PlaylistUpdater.delete(playlist);
} catch (err) {
console.error(err);
socket.emit('app-error', { message: err.message });
async function handleDeletePlaylist({ playlist }, io, socket) {
try {
// Check if user is authenticated as admin from the socket middleware
if (authService.isAdminEnabled() && !socket.user?.isAdmin) {
return socket.emit("app-error", {
message: "Admin access required to delete playlists",
});
}
const channels = await PlaylistService.deletePlaylist(playlist);
channels.forEach((channel) => {
io.emit("channel-deleted", channel.id);
});
io.emit("channel-selected", ChannelService.getCurrentChannel());
PlaylistUpdater.delete(playlist);
} catch (err) {
console.error(err);
socket.emit("app-error", { message: err.message });
}
}
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("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)
);
};

View File

@@ -0,0 +1,23 @@
const authService = require("../../services/auth/AuthService");
/**
* Socket.io middleware to authenticate users via JWT token
*/
function socketAuthMiddleware(socket, next) {
// Retrieve token from handshake auth or query param
const token = socket.handshake.auth.token || socket.handshake.query.token;
if (!token) {
// Allow connection but without admin privileges
socket.user = { isAdmin: false };
return next();
}
const decoded = authService.verifyToken(token);
// Attach the decoded user info (or default non-admin) to the socket
socket.user = decoded || { isAdmin: false };
return next();
}
module.exports = socketAuthMiddleware;

View File

@@ -1,4 +1,14 @@
# Deployment
# Advanced Deployment
## Configuration Options
### Admin Mode
Admin Mode restricts channel management to authenticated administrators only.
**Configuration (in `docker-compose.yml` > iptv_restream_backend):**
- `ADMIN_ENABLED`: Enable admin mode (`true` or `false` [default]).
- `ADMIN_PASSWORD`: Set a secure password for admin login (required if admin mode is enabled).
- `CHANNEL_SELECTION_REQUIRES_ADMIN`: If set to `true`, only admins can switch the currently watched channel.
## Docker

View File

@@ -30,6 +30,11 @@ 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"
# CHANNEL_SELECTION_REQUIRES_ADMIN: "true"
networks:
- app-network

View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"hls.js": "^1.6.0-beta.2",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -2749,6 +2750,15 @@
"node": ">=6"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5934,6 +5944,11 @@
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true
},
"jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="
},
"keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"hls.js": "^1.6.0-beta.2",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
import { Search, Plus, Settings, Users, Radio, Tv2, ChevronDown } from 'lucide-react';
import { useState, useEffect, useMemo, useContext } from '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';
@@ -9,15 +9,19 @@ import socketService from './services/SocketService';
import apiService from './services/ApiService';
import SettingsModal from './components/SettingsModal';
import TvPlaylistModal from './components/TvPlaylistModal';
import { ToastProvider } from './components/notifications/ToastContext';
import { ToastProvider, ToastContext } from './components/notifications/ToastContext';
import ToastContainer from './components/notifications/ToastContainer';
import { AdminProvider, useAdmin } from './components/admin/AdminContext';
import AdminModal from './components/admin/AdminModal';
function AppContent() {
function App() {
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 +34,9 @@ function App() {
const [isPlaylistDropdownOpen, setIsPlaylistDropdownOpen] = useState(false);
const [isGroupDropdownOpen, setIsGroupDropdownOpen] = useState(false);
const { isAdmin, isAdminEnabled, setIsAdminEnabled, channelSelectRequiresAdmin, setChannelSelectRequiresAdmin } = useAdmin();
const { addToast } = useContext(ToastContext);
// Get unique playlists from channels
const playlists = useMemo(() => {
const uniquePlaylists = new Set(channels.map(channel => channel.playlistName).filter(playlistName => playlistName !== null));
@@ -55,7 +62,7 @@ function App() {
const groups = useMemo(() => {
let uniqueGroups;
if(selectedPlaylist === 'All Channels') {
if (selectedPlaylist === 'All Channels') {
uniqueGroups = new Set(channels.map(channel => channel.group).filter(group => group !== null));
} else {
uniqueGroups = new Set(channels.filter(channel => channel.group !== null && channel.playlistName === selectedPlaylist).map(channel => channel.group));
@@ -64,6 +71,15 @@ function App() {
}, [selectedPlaylist, channels]);
useEffect(() => {
// Check if admin mode is enabled on the server
apiService
.request<{ enabled: boolean; channelSelectionRequiresAdmin: boolean }>('/auth/admin-status', 'GET')
.then((data) => {
setIsAdminEnabled(data.enabled);
setChannelSelectRequiresAdmin(data.channelSelectionRequiresAdmin);
})
.catch((error) => console.error('Error checking admin status:', error));
apiService
.request<Channel[]>('/channels/', 'GET')
.then((data) => setChannels(data))
@@ -86,26 +102,29 @@ function App() {
const channelUpdatedListener = (updatedChannel: Channel) => {
setChannels((prevChannels) =>
prevChannels.map((channel) =>
channel.id === updatedChannel.id ? updatedChannel : channel
channel.id === updatedChannel.id ?
updatedChannel : channel
)
);
setSelectedChannel((selectedChannel: Channel | null) => {
if(selectedChannel?.id === updatedChannel.id) {
// Reload stream if the stream attributes (url, headers) have changed
if((selectedChannel?.url != updatedChannel.url || JSON.stringify(selectedChannel?.headers) != JSON.stringify(updatedChannel.headers)) && selectedChannel?.mode === 'restream'){
//TODO: find a better solution instead of reloading (problem is m3u8 needs time to refresh server-side)
setTimeout(() => {
window.location.reload();
}, 3000);
}
return updatedChannel;
if (selectedChannel?.id === updatedChannel.id) {
// Reload stream if the stream attributes (url, headers) have changed
if (
(selectedChannel?.url != updatedChannel.url ||
JSON.stringify(selectedChannel?.headers) !=
JSON.stringify(updatedChannel.headers)) &&
selectedChannel?.mode === 'restream'
) {
//TODO: find a better solution instead of reloading (problem is m3u8 needs time to refresh server-side)
setTimeout(() => {
window.location.reload();
}, 3000);
}
return selectedChannel;
return updatedChannel;
}
);
return selectedChannel;
});
};
const channelDeletedListener = (deletedChannel: number) => {
@@ -114,215 +133,281 @@ function App() {
);
};
const errorListener = (error: { message: string }) => {
addToast({
type: 'error',
title: 'Error',
message: error.message,
duration: 5000,
});
};
socketService.subscribeToEvent('channel-added', channelAddedListener);
socketService.subscribeToEvent('channel-selected', channelSelectedListener);
socketService.subscribeToEvent('channel-updated', channelUpdatedListener);
socketService.subscribeToEvent('channel-deleted', channelDeletedListener);
socketService.subscribeToEvent('app-error', errorListener);
socketService.connect();
return () => {
socketService.unsubscribeFromEvent('channel-added', channelAddedListener);
socketService.unsubscribeFromEvent('channel-selected', channelSelectedListener);
socketService.unsubscribeFromEvent('channel-updated', channelUpdatedListener);
socketService.unsubscribeFromEvent('channel-deleted', channelDeletedListener);
socketService.unsubscribeFromEvent(
'channel-selected',
channelSelectedListener
);
socketService.unsubscribeFromEvent(
'channel-updated',
channelUpdatedListener
);
socketService.unsubscribeFromEvent(
'channel-deleted',
channelDeletedListener
);
socketService.unsubscribeFromEvent('app-error', errorListener);
socketService.disconnect();
console.log('WebSocket connection closed');
};
}, []);
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}
onChannelSelectCheckPermission={() => {
if (isAdminEnabled && channelSelectRequiresAdmin && !isAdmin) {
setIsAdminModalOpen(true);
return false;
}
return true;
}}
/>
</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}
/>
)}
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
syncEnabled={syncEnabled}
onSyncChange={(enabled) => {
setSyncEnabled(enabled);
localStorage.setItem('syncEnabled', JSON.stringify(enabled));
}}
/>
<TvPlaylistModal
isOpen={isTvPlaylistOpen}
onClose={() => setIsTvPlaylistOpen(false)}
/>
<AdminModal
isOpen={isAdminModalOpen}
onClose={() => setIsAdminModalOpen(false)}
/>
<ToastContainer />
</div>
);
}
function App() {
return (
<ToastProvider>
<AdminProvider>
<AppContent />
</AdminProvider>
</ToastProvider>
);
}

View File

@@ -1,19 +1,27 @@
import React from 'react';
import { Channel } from '../types';
import socketService from '../services/SocketService';
import React from "react";
import { Channel } from "../types";
import socketService from "../services/SocketService";
interface ChannelListProps {
channels: Channel[];
selectedChannel: Channel | null;
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
onEditChannel: (channel: Channel) => void;
onChannelSelectCheckPermission: () => boolean;
}
function ChannelList({ channels, selectedChannel, setSearchQuery, onEditChannel }: ChannelListProps) {
function ChannelList({
channels,
selectedChannel,
setSearchQuery,
onEditChannel,
onChannelSelectCheckPermission,
}: ChannelListProps) {
const onSelectChannel = (channel: Channel) => {
setSearchQuery('');
if(channel.id === selectedChannel?.id) return;
setSearchQuery("");
if (channel.id === selectedChannel?.id) return;
if (!onChannelSelectCheckPermission()) return;
socketService.setCurrentChannel(channel.id);
};
@@ -27,13 +35,13 @@ function ChannelList({ channels, selectedChannel, setSearchQuery, onEditChannel
{channels.map((channel) => (
<button
key={channel.id}
title={channel.name.length > 28 ? channel.name : ''}
title={channel.name.length > 28 ? channel.name : ""}
onClick={() => onSelectChannel(channel)}
onContextMenu={(event) => onRightClickChannel(event, channel)}
className={`group relative p-2 rounded-lg transition-all ${
selectedChannel?.id === channel.id
? 'bg-blue-500 bg-opacity-20 ring-2 ring-blue-500'
: 'hover:bg-gray-700'
? "bg-blue-500 bg-opacity-20 ring-2 ring-blue-500"
: "hover:bg-gray-700"
}`}
>
<div className="h-20 w-20 mb-2 flex items-center justify-center rounded-lg mx-auto">
@@ -44,7 +52,9 @@ function ChannelList({ channels, selectedChannel, setSearchQuery, onEditChannel
/>
</div>
<p className="text-sm font-medium truncate text-center">
{channel.name.length > 28 ? `${channel.name.substring(0, 28)}...` : channel.name}
{channel.name.length > 28
? `${channel.name.substring(0, 28)}...`
: channel.name}
</p>
</button>
))}

View File

@@ -1,6 +1,7 @@
import { X, Copy, Tv2 } from 'lucide-react';
import { useContext } from 'react';
import { ToastContext } from './notifications/ToastContext';
import { useAdmin } from './admin/AdminContext';
interface TvPlaylistModalProps {
isOpen: boolean;
@@ -8,6 +9,7 @@ interface TvPlaylistModalProps {
}
function TvPlaylistModal({ isOpen, onClose }: TvPlaylistModalProps) {
const { isAdmin } = useAdmin();
const { addToast } = useContext(ToastContext);
const playlistUrl = `${import.meta.env.VITE_BACKEND_URL || window.location.origin}/api/channels/playlist`;
@@ -66,6 +68,22 @@ 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>
</div>
<div className="bg-gray-900 rounded-lg p-4">
<p className="text-sm text-gray-300">
This playlist contains all stream URLs. You can share a link to the
application with other users, and they will be able to watch the streams
together with you.
</p>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -100,7 +100,7 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
url.trim(),
avatar.trim() || 'https://via.placeholder.com/64',
mode,
JSON.stringify(headers)
JSON.stringify(headers),
);
} else if (type === 'playlist') {
if (inputMethod === 'url' && !playlistUrl.trim()) return;
@@ -111,7 +111,7 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
playlistName.trim(),
mode,
playlistUpdate,
JSON.stringify(headers)
JSON.stringify(headers),
);
}
@@ -221,9 +221,11 @@ 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>
</div>
<input
type="url"
id="url"
@@ -443,14 +445,16 @@ 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>
<div className="flex items-center space-x-2">
<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>
<div className="space-y-2">
{headers && headers.map((header, index) => (

View File

@@ -0,0 +1,99 @@
import React, {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from 'react';
import { jwtDecode } from 'jwt-decode';
import socketService from '../../services/SocketService';
interface AdminContextType {
isAdmin: boolean | null;
setIsAdmin: (value: boolean) => void;
isAdminEnabled: boolean;
setIsAdminEnabled: (value: boolean) => void;
channelSelectRequiresAdmin: boolean;
setChannelSelectRequiresAdmin: (value: boolean) => void;
adminToken: string | null;
}
const AdminContext = createContext<AdminContextType>({
isAdmin: false,
setIsAdmin: () => {},
isAdminEnabled: false,
setIsAdminEnabled: () => {},
channelSelectRequiresAdmin: false,
setChannelSelectRequiresAdmin: () => {},
adminToken: null,
});
export const useAdmin = () => useContext(AdminContext);
interface AdminProviderProps {
children: ReactNode;
}
// Helper function to check if token is valid
const isTokenValid = (token: string): boolean => {
try {
const decoded: any = jwtDecode(token);
// Check if token is expired
return decoded.exp * 1000 > Date.now();
} catch {
return false;
}
};
export const AdminProvider: React.FC<AdminProviderProps> = ({ children }) => {
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [isAdminEnabled, setIsAdminEnabled] = useState(false);
const [channelSelectRequiresAdmin, setChannelSelectRequiresAdmin] = useState(false);
const [adminToken, setAdminToken] = useState<string | null>(null);
// Effect to handle token changes
useEffect(() => {
// When admin status changes, update socket connection
if (isAdmin === true) {
// Small delay to ensure token is saved before reconnecting
setTimeout(() => {
socketService.updateAuthToken();
}, 100);
} else if (isAdmin === false) {
// Reset token and reconnect
localStorage.removeItem("admin_token");
setAdminToken(null);
socketService.updateAuthToken();
}
}, [isAdmin]);
// Initial setup - check for existing token
useEffect(() => {
// Check if there's a token in localStorage on component mount
const token = localStorage.getItem('admin_token');
if (token && isTokenValid(token)) {
setIsAdmin(true);
setAdminToken(token);
} else if (token) {
// Clear invalid token
setIsAdmin(false);
}
}, []);
return (
<AdminContext.Provider
value={{
isAdmin,
setIsAdmin,
isAdminEnabled,
setIsAdminEnabled,
channelSelectRequiresAdmin,
setChannelSelectRequiresAdmin,
adminToken,
}}
>
{children}
</AdminContext.Provider>
);
};

View File

@@ -0,0 +1,130 @@
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, token?: string}>('/auth/admin-login', 'POST', undefined, {
password
});
if (response.success && response.token) {
// Store JWT token in localStorage
localStorage.setItem('admin_token', response.token);
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 = () => {
// Remove JWT token from localStorage
localStorage.removeItem('admin_token');
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

@@ -4,12 +4,12 @@ type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
const apiService = {
/**
* Execute API request
* Execute API request with JWT auth token (if available)
* @param path - Path (e.g. "/channels/")
* @param method - HTTP-Method (GET, POST, etc.)
* @param api_url - The API URL (default: API_BASE_URL + '/api')
* @param body - The request body (e.g. POST)
* @returns Ein Promise with the parsed JSON response to class T
* @returns A Promise with the parsed JSON response to class T
*/
async request<T>(path: string, method: HttpMethod = 'GET', api_url: string = API_BASE_URL + '/api', body?: unknown): Promise<T> {
try {
@@ -17,9 +17,15 @@ const apiService = {
method,
headers: {
'Content-Type': 'application/json',
},
} as Record<string, string>,
};
// Add Authorization header if JWT token exists
const token = localStorage.getItem('admin_token');
if (token) {
(options.headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
if (body) {
options.body = JSON.stringify(body);
}

View File

@@ -3,30 +3,73 @@ import { ChannelMode } from '../types';
class SocketService {
private socket: Socket | null = null;
private listeners: Map<string, ((data: any) => void)[]> = new Map();
private isConnecting: boolean = false;
private token: string | null = null;
// Initialize
// Initialize connection with JWT token if available
connect() {
if (this.socket?.connected) return;
// Get JWT token from localStorage
const newToken = localStorage.getItem('admin_token');
console.log('Connecting to WebSocket server: ');
// Default Behavior: If 'VITE_BACKEND_URL' is not set, the app will use the same host name as the frontend
this.socket = io(import.meta.env.VITE_BACKEND_URL);
// If already connected with the same token, don't reconnect
if (this.socket?.connected && this.token === newToken) {
return;
}
// If connecting with the same token, don't try to connect again
if (this.isConnecting && this.token === newToken) {
return;
}
this.isConnecting = true;
this.token = newToken;
console.log('Connecting to WebSocket server');
// Disconnect existing socket if necessary
if (this.socket) {
// Save listeners before disconnecting
const savedListeners = new Map(this.listeners);
// Disconnect and reset the socket
this.socket.disconnect();
this.socket = null;
// Restore listeners
this.listeners = savedListeners;
}
// Connect with auth token if available
this.socket = io(import.meta.env.VITE_BACKEND_URL, {
auth: this.token ? { token: this.token } : undefined,
});
this.socket.on('connect', () => {
console.log('Connected to WebSocket server');
console.log(
'Connected to WebSocket server with auth:',
this.token ? 'yes' : 'no'
);
this.isConnecting = false;
// Re-apply listeners to new socket connection
this.reapplyListeners();
});
this.socket.on('disconnect', () => {
console.log('Disconnected from WebSocket server');
this.isConnecting = false;
});
this.socket.on('connect_error', (error) => {
console.error('Connection error:', error);
this.isConnecting = false;
});
this.socket.on('app-error', (error) => {
console.error('Failed:', error);
console.error('Socket error:', error);
});
// Listen for incoming custom events
this.socket.onAny((event: string, data: any) => {
const eventListeners = this.listeners.get(event);
@@ -36,10 +79,16 @@ class SocketService {
});
}
// Re-apply all event listeners to the new socket connection
private reapplyListeners() {
// Nothing needed here as Socket.IO automatically handles event listeners
}
disconnect() {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
this.isConnecting = false;
}
}
@@ -47,7 +96,11 @@ class SocketService {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)?.push(listener);
const eventListeners = this.listeners.get(event);
// Avoid duplicate listeners
if (eventListeners && !eventListeners.includes(listener)) {
eventListeners.push(listener);
}
}
// Unsubscribe from event
@@ -56,68 +109,159 @@ class SocketService {
if (eventListeners) {
this.listeners.set(
event,
eventListeners.filter((existingListener) => existingListener !== listener)
eventListeners.filter(
(existingListener) => existingListener !== listener
)
);
}
}
// Send chat message
sendMessage(userName: string, userAvatar: string, message: string, timestamp: string) {
if (!this.socket) throw new Error('Socket is not connected.');
sendMessage(
userName: string,
userAvatar: string,
message: string,
timestamp: string
) {
if (!this.socket || !this.socket.connected) {
this.connect();
this.socket.emit('send-message', { userName, userAvatar, message, timestamp });
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('send-message', {
userName,
userAvatar,
message,
timestamp,
});
}
// Add channel
addChannel(name: string, url: string, avatar: string, mode: ChannelMode, headersJson: string) {
if (!this.socket) throw new Error('Socket is not connected.');
addChannel(
name: string,
url: string,
avatar: string,
mode: ChannelMode,
headersJson: string,
) {
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('add-channel', { name, url, avatar, mode, headersJson });
}
// Set current channel
setCurrentChannel(id: number) {
if (!this.socket) throw new Error('Socket is not connected.');
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('set-current-channel', id);
}
// Delete channel
deleteChannel(id: number) {
if (!this.socket) throw new Error('Socket is not connected.');
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('delete-channel', id);
}
// Update channel
updateChannel(id: number, updatedAttributes: any) {
if (!this.socket) throw new Error('Socket is not connected.');
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('update-channel', { id, updatedAttributes });
}
// Add playlist
addPlaylist(playlist: string, playlistName: string, mode: ChannelMode, playlistUpdate: boolean, headers: string) {
if (!this.socket) throw new Error('Socket is not connected.');
addPlaylist(
playlist: string,
playlistName: string,
mode: ChannelMode,
playlistUpdate: boolean,
headers: string,
) {
if (!this.socket || !this.socket.connected) {
this.connect();
this.socket.emit('add-playlist', { playlist, playlistName, mode, playlistUpdate, headers });
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('add-playlist', {
playlist,
playlistName,
mode,
playlistUpdate,
headers,
});
}
// Update playlist
updatePlaylist(playlist: string, updatedAttributes: any) {
if (!this.socket) throw new Error('Socket is not connected.');
updatePlaylist(
playlist: string,
updatedAttributes: any,
) {
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('update-playlist', { playlist, updatedAttributes });
}
// Delete playlist
deletePlaylist(playlist: string) {
if (!this.socket) throw new Error('Socket is not connected.');
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('delete-playlist', playlist);
}
// Update authentication token and reconnect
updateAuthToken() {
// Force disconnect and reconnect with the new token
this.disconnect();
// Reset the token so connect() will use the new one from localStorage
this.token = null;
// Connect with the new token
this.connect();
}
}
const socketService = new SocketService();