Merge pull request #65 from aronjanosch/simple-admin-mode
Admin mode for limiting access to channel management
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -43,3 +43,6 @@ Thumbs.db
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
|
||||
.devcontainer/
|
||||
25
README.md
25
README.md
@@ -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.
|
||||
|
||||
58
backend/controllers/AuthController.js
Normal file
58
backend/controllers/AuthController.js
Normal 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();
|
||||
},
|
||||
};
|
||||
126
backend/package-lock.json
generated
126
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
82
backend/services/auth/AuthService.js
Normal file
82
backend/services/auth/AuthService.js
Normal 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();
|
||||
@@ -1,46 +1,86 @@
|
||||
const ChannelService = require('../services/ChannelService');
|
||||
const ChannelService = require("../services/ChannelService");
|
||||
const authService = require("../services/auth/AuthService");
|
||||
|
||||
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 }) => {
|
||||
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
|
||||
// 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",
|
||||
});
|
||||
}
|
||||
|
||||
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.emit("app-error", { message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('set-current-channel', async (id) => {
|
||||
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
|
||||
io.emit("channel-selected", nextChannel); // Broadcast to all clients
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
socket.emit('app-error', { message: err.message });
|
||||
socket.emit("app-error", { message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('delete-channel', async (id) => {
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
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.emit("app-error", { message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('update-channel', async ({ id, updatedAttributes }) => {
|
||||
socket.on("update-channel", async ({ id, updatedAttributes }) => {
|
||||
try {
|
||||
const updatedChannel = await ChannelService.updateChannel(id, updatedAttributes);
|
||||
io.emit('channel-updated', updatedChannel); // Broadcast to all clients
|
||||
// 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 });
|
||||
socket.emit("app-error", { message: err.message });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
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);
|
||||
// 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",
|
||||
});
|
||||
}
|
||||
|
||||
if(playlistUpdate) {
|
||||
PlaylistUpdater.register(new Playlist(playlist, playlistName, mode, playlistUpdate, headers));
|
||||
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 });
|
||||
socket.emit("app-error", { message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdatePlaylist({ playlist, updatedAttributes }, io, socket) {
|
||||
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);
|
||||
await handleDeletePlaylist({ playlist }, io, socket);
|
||||
await handleAddPlaylist({ ...updatedAttributes }, io, socket);
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = await PlaylistService.updatePlaylist(playlist, updatedAttributes);
|
||||
const channels = await PlaylistService.updatePlaylist(
|
||||
playlist,
|
||||
updatedAttributes
|
||||
);
|
||||
|
||||
channels.forEach(channel => {
|
||||
io.emit('channel-updated', channel);
|
||||
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));
|
||||
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 });
|
||||
socket.emit("app-error", { message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePlaylist(playlist, io, socket) {
|
||||
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);
|
||||
channels.forEach((channel) => {
|
||||
io.emit("channel-deleted", channel.id);
|
||||
});
|
||||
io.emit('channel-selected', ChannelService.getCurrentChannel());
|
||||
io.emit("channel-selected", ChannelService.getCurrentChannel());
|
||||
|
||||
PlaylistUpdater.delete(playlist);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
socket.emit('app-error', { message: err.message });
|
||||
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)
|
||||
);
|
||||
};
|
||||
23
backend/socket/middleware/jwt.js
Normal file
23
backend/socket/middleware/jwt.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,15 +102,20 @@ 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) {
|
||||
|
||||
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'){
|
||||
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();
|
||||
@@ -103,9 +124,7 @@ function App() {
|
||||
return updatedChannel;
|
||||
}
|
||||
return selectedChannel;
|
||||
}
|
||||
);
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
const channelDeletedListener = (deletedChannel: number) => {
|
||||
@@ -114,36 +133,67 @@ 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) => {
|
||||
// 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>
|
||||
|
||||
{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
|
||||
@@ -169,6 +219,15 @@ function App() {
|
||||
>
|
||||
<Settings className="w-6 h-6 text-blue-500" />
|
||||
</button>
|
||||
{isAdminEnabled && (
|
||||
<button
|
||||
onClick={() => setIsAdminModalOpen(true)}
|
||||
className={`p-2 hover:bg-gray-800 rounded-lg transition-colors ${isAdmin ?
|
||||
"text-green-500" : ""}`}
|
||||
>
|
||||
<Shield className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -191,7 +250,8 @@ function App() {
|
||||
{selectedPlaylist}
|
||||
</h2>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${isPlaylistDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${isPlaylistDropdownOpen ?
|
||||
"rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{isPlaylistDropdownOpen && (
|
||||
@@ -205,9 +265,8 @@ function App() {
|
||||
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'
|
||||
}`}
|
||||
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',
|
||||
@@ -236,7 +295,8 @@ function App() {
|
||||
{selectedGroup}
|
||||
</h4>
|
||||
</div>
|
||||
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform duration-200 ${isGroupDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform duration-200 ${isGroupDropdownOpen ?
|
||||
"rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{isGroupDropdownOpen && (
|
||||
@@ -249,9 +309,8 @@ function App() {
|
||||
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'
|
||||
}`}
|
||||
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',
|
||||
@@ -269,9 +328,14 @@ function App() {
|
||||
|
||||
<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);
|
||||
} else {
|
||||
setIsAdminModalOpen(true);
|
||||
}
|
||||
}}
|
||||
className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
@@ -284,6 +348,13 @@ function App() {
|
||||
selectedChannel={selectedChannel}
|
||||
setSearchQuery={setSearchQuery}
|
||||
onEditChannel={handleEditChannel}
|
||||
onChannelSelectCheckPermission={() => {
|
||||
if (isAdminEnabled && channelSelectRequiresAdmin && !isAdmin) {
|
||||
setIsAdminModalOpen(true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -321,8 +392,22 @@ function App() {
|
||||
onClose={() => setIsTvPlaylistOpen(false)}
|
||||
/>
|
||||
|
||||
<AdminModal
|
||||
isOpen={isAdminModalOpen}
|
||||
onClose={() => setIsAdminModalOpen(false)}
|
||||
/>
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<AdminProvider>
|
||||
<AppContent />
|
||||
</AdminProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<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,6 +445,7 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
<label className="block text-sm font-medium">
|
||||
Custom Headers
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHeader}
|
||||
@@ -452,6 +455,7 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
<span>Add Header</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{headers && headers.map((header, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
|
||||
99
frontend/src/components/admin/AdminContext.tsx
Normal file
99
frontend/src/components/admin/AdminContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
130
frontend/src/components/admin/AdminModal.tsx
Normal file
130
frontend/src/components/admin/AdminModal.tsx
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user