[mirotalk] - #285 isolate mattermost integration from host auth systems

This commit is contained in:
Miroslav Pejic
2025-04-19 01:52:09 +02:00
parent aece756762
commit 4cb3ac2270
10 changed files with 439 additions and 60 deletions
+1
View File
@@ -158,6 +158,7 @@ MATTERMOST_SERVER_URL=YourMattermostServerUrl
MATTERMOST_USERNAME=YourMattermostUsername
MATTERMOST_PASSWORD=YourMattermostPassword
MATTERMOST_TOKEN=YourMettarmostToken
MATTERMOST_ROOM_TOKEN_EXPIRE=15m
# ChatGPT/OpenAI
# 1. Goto https://platform.openai.com/
+2 -2
View File
@@ -8,7 +8,7 @@ module.exports = {
app: {
language: 'en', // https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
name: 'MiroTalk',
title: '<h1>MiroTalk</h1><br />Free browser based Real-time video calls.<br />Simple, Secure, Fast.',
title: '<h1>MiroTalk</h1>Free browser based Real-time video calls.<br />Simple, Secure, Fast.',
description:
'Start your next video call with a single click. No download, plug-in, or login is required. Just get straight to talking, messaging, and sharing your screen.',
joinDescription: 'Pick a room name.<br />How about this one?',
@@ -65,7 +65,7 @@ module.exports = {
href="https://www.linkedin.com/in/miroslav-pejic-976a07101/" target="_blank">
Miroslav Pejic
</a>
<br /><br />
<br />
Email:<a
id="email-button"
data-umami-event="Email button"
+127 -39
View File
@@ -1,78 +1,166 @@
'use strict';
const { Client4 } = require('@mattermost/client');
const { v4: uuidV4 } = require('uuid');
const TokenManager = require('./tokenManager');
const Logger = require('./logs');
const log = new Logger('Mattermost');
class mattermost {
constructor(app, mattermostCfg) {
if (!this.isConfigValid(mattermostCfg)) return;
class TokenService {
constructor(secret, expiresIn, encryptionKey) {
this.tokenManager = new TokenManager(secret, expiresIn, encryptionKey);
}
this.app = app;
this.disabled = mattermostCfg.api_disabled;
this.token = mattermostCfg.token;
this.serverUrl = mattermostCfg.server_url;
this.username = mattermostCfg.username;
this.password = mattermostCfg.password;
createToken(payload) {
return this.tokenManager.create(payload, true);
}
decodeToken(token) {
return this.tokenManager.decodePayload(token);
}
}
class MattermostService {
constructor(config) {
this.validateConfig(config);
this.token = config.token;
this.serverUrl = config.server_url;
this.username = config.username;
this.password = config.password;
this.disabledEndpoints = config.api_disabled || [];
this.client = new Client4();
this.client.setUrl(this.serverUrl);
this.authenticate();
this.setupEventHandlers();
this.tokenService = new TokenService(
this.token || 'fallback-secret-at-least-32-chars',
config.roomTokenExpire || '15m',
config.encryptionKey || 'fallback-encryption-key-32chars'
);
}
isConfigValid(config) {
return config.enabled && config.server_url && config.token && config.username && config.password;
validateConfig(config) {
if (!config.enabled || !config.server_url || !config.token || !config.username || !config.password) {
throw new Error('Invalid Mattermost configuration');
}
}
async authenticate() {
try {
const user = await this.client.login(this.username, this.password);
log.debug('--------> Logged into Mattermost as', user.username);
log.debug('Logged into Mattermost as', user.username);
} catch (error) {
log.error('Failed to log into Mattermost:', error);
}
}
setupEventHandlers() {
isEndpointDisabled(endpoint) {
return this.disabledEndpoints.includes(endpoint);
}
createMeetingToken(userId) {
const payload = {
userId,
roomId: uuidV4(),
timestamp: Date.now(),
};
const token = this.tokenService.createToken(payload);
return { token, payload };
}
validateToken(token) {
return this.tokenService.decodeToken(token);
}
getMeetingURL(req, roomToken) {
const host = req.headers.host;
const protocol = host.includes('localhost') ? 'http' : 'https';
return `${protocol}://${host}/mattermost/join/${encodeURIComponent(roomToken)}`;
}
}
class MattermostController {
constructor(app, mattermostCfg, htmlInjector, clientHtml) {
try {
this.service = new MattermostService(mattermostCfg);
} catch (error) {
log.error('MattermostController disabled due to config error:', error.message);
return;
}
this.htmlInjector = htmlInjector;
this.clientHtml = clientHtml;
this.app = app;
this.token = mattermostCfg.token;
this.service.authenticate();
this.setupRoutes();
}
setupRoutes() {
this.app.post('/mattermost', (req, res) => {
// Check if endpoint allowed
if (this.disabled.includes('mattermost')) {
return res.end(
'`This endpoint has been disabled`. Please contact the administrator for further information.',
);
if (this.service.isEndpointDisabled('mattermost')) {
return res.end('`This endpoint has been disabled`. Please contact the administrator.');
}
// Validate the token
const { token, text, command, channel_id } = req.body;
const { token, text, command, channel_id, user_id } = req.body;
if (token !== this.token) {
log.error('Invalid token attempt', { token });
return res.status(403).send('Invalid token');
}
// Check if the command (slash-commands) or text (outgoing-webhook) matches "/p2p"
if (command.trim() === '/p2p' || text.trim() === '/p2p') {
const meetingUrl = this.getMeetingURL(req);
return res.json({
text: `Here is your meeting room: ${meetingUrl}`,
channel_id: channel_id,
});
if (command?.trim() === '/p2p' || text?.trim() === '/p2p') {
try {
const { token: roomToken } = this.service.createMeetingToken(user_id);
const meetingUrl = this.service.getMeetingURL(req, roomToken);
return res.json({
response_type: 'in_channel',
text: `🔗 [Click here to join your private meeting](${meetingUrl})`,
channel_id,
});
} catch (error) {
log.error('Token creation failed', error);
return res.status(500).send('Error creating meeting');
}
}
// If the command is not recognized
return res.status(404).send('Command not recognized');
});
}
getMeetingURL(req) {
const host = req.headers.host;
const protocol = host.includes('localhost') ? 'http' : 'https';
return `${protocol}://${host}/join/${uuidV4()}`;
this.app.get('/mattermost/join/:roomToken', (req, res) => {
if (this.service.isEndpointDisabled('mattermost')) {
return res.end('This endpoint has been disabled');
}
const { roomToken } = req.params;
if (!roomToken) {
return res.status(401).send('Token required');
}
try {
const payload = this.service.validateToken(roomToken);
log.debug('Decoded payload', payload);
if (!payload || !payload.userId || !payload.roomId) {
log.error('Invalid or malformed token payload', payload);
return res.status(400).send('Invalid token');
}
return this.htmlInjector.injectHtml(this.clientHtml, res);
} catch (error) {
log.error('Token processing error', {
error: error.message,
token: roomToken.substring(0, 20) + '...',
});
return res.status(500).send('Error processing token');
}
});
}
}
module.exports = mattermost;
module.exports = MattermostController;
+19 -12
View File
@@ -45,7 +45,7 @@ dependencies: {
* @license For commercial use or closed source, contact us at license.mirotalk@gmail.com or purchase directly from CodeCanyon
* @license CodeCanyon: https://codecanyon.net/item/mirotalk-p2p-webrtc-realtime-video-conferences/38376661
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
* @version 1.5.03
* @version 1.5.04
*
*/
@@ -67,7 +67,7 @@ const app = express();
const fs = require('fs');
const checkXSS = require('./xss.js');
const ServerApi = require('./api');
const mattermostCli = require('./mattermost');
const MattermostController = require('./mattermost');
const Validate = require('./validate');
const HtmlInjector = require('./htmlInjector');
const Host = require('./host');
@@ -272,6 +272,8 @@ const mattermostCfg = {
username: process.env.MATTERMOST_USERNAME,
password: process.env.MATTERMOST_PASSWORD,
token: process.env.MATTERMOST_TOKEN,
roomTokenExpire: process.env.MATTERMOST_ROOM_TOKEN_EXPIRE,
encryptionKey: process.env.JWT_KEY,
api_disabled: api_disabled,
};
@@ -384,15 +386,20 @@ app.set('trust proxy', trustProxy); // Enables trust for proxy headers (e.g., X-
app.use(helmet.noSniff()); // Enable content type sniffing prevention
// Use all static files from the public folder
app.use(
express.static(dir.public, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.js')) {
res.setHeader('Content-Type', 'application/javascript');
} //...
},
}),
);
const staticOptions = {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.js')) {
res.setHeader('Content-Type', 'application/javascript');
}
// Add other headers if needed...
},
};
// Serve static files from root (/)
app.use(express.static(dir.public, staticOptions));
// Also serve the same files under /mattermost
app.use('/mattermost', express.static(dir.public, staticOptions));
app.use(cors(corsOptions)); // Enable CORS with options
app.use(compression()); // Compress all HTTP responses using GZip
@@ -426,7 +433,7 @@ app.use((req, res, next) => {
});
// Mattermost
const mattermost = new mattermostCli(app, mattermostCfg);
const mattermost = new MattermostController(app, mattermostCfg, htmlInjector, views.client);
// Remove trailing slashes in url handle bad requests
app.use((err, req, res, next) => {
+271
View File
@@ -0,0 +1,271 @@
'use strict';
const jwt = require('jsonwebtoken');
const CryptoJS = require('crypto-js');
const Logger = require('./logs');
/**
* Handles JWT signing and verification.
*/
class JWTService {
/**
* @param {string} secret - JWT secret key.
* @param {string} algorithm - Signing algorithm (default: 'HS256').
*/
constructor(secret, algorithm = 'HS256') {
this.secret = secret;
this.algorithm = algorithm;
}
/**
* Signs a payload into a JWT token.
* @param {Object} payload
* @param {string|number} expiresIn - Expiration time (e.g., '1h', 3600).
* @returns {string}
*/
sign(payload, expiresIn) {
return jwt.sign(payload, this.secret, {
expiresIn,
algorithm: this.algorithm,
});
}
/**
* Verifies a JWT token.
* @param {string} token
* @param {boolean} [ignoreExpiration=false]
* @returns {Object}
*/
verify(token, ignoreExpiration = false) {
return jwt.verify(token, this.secret, {
ignoreExpiration,
algorithms: [this.algorithm],
clockTolerance: 30,
});
}
/**
* Decodes a JWT token without verification.
* @param {string} token
* @returns {Object|null}
*/
decode(token) {
return jwt.decode(token);
}
}
/**
* Handles AES encryption and decryption.
*/
class EncryptionService {
/**
* @param {string} encryptionKey - Secret key for encryption/decryption.
*/
constructor(encryptionKey) {
this.encryptionKey = encryptionKey;
}
/**
* Encrypts a payload using AES.
* @param {any} payload
* @param {string} [type=typeof payload]
* @returns {{ encrypted: string, type: string }}
*/
encrypt(payload, type = typeof payload) {
const stringified = type === 'object' ? JSON.stringify(payload) : String(payload);
return {
encrypted: CryptoJS.AES.encrypt(stringified, this.encryptionKey).toString(),
type,
};
}
/**
* Decrypts AES-encrypted data.
* @param {{ encrypted: string, type: string }} encryptedPayload
* @returns {any}
*/
decrypt(encryptedPayload) {
const { encrypted, type } = encryptedPayload;
const bytes = CryptoJS.AES.decrypt(encrypted, this.encryptionKey);
const decrypted = bytes.toString(CryptoJS.enc.Utf8);
if (!decrypted) throw new Error('Decryption failed');
switch (type) {
case 'object': return JSON.parse(decrypted);
case 'number': return Number(decrypted);
default: return decrypted;
}
}
}
/**
* Compares payloads for token validation.
*/
class PayloadComparator {
/**
* Checks if actual matches expected payload.
* @param {Object} expected
* @param {Object} actual
* @returns {boolean}
*/
static match(expected, actual) {
if (typeof expected === 'object') {
return Object.keys(expected).every(key =>
JSON.stringify(expected[key]) === JSON.stringify(actual[key])
);
}
const value = actual?.value ?? actual;
return String(value) === String(expected);
}
/**
* Returns the difference between expected and actual payloads.
* @param {Object} expected
* @param {Object} actual
* @returns {Object}
*/
static diff(expected, actual) {
const diff = {};
Object.keys(expected).forEach(key => {
if (JSON.stringify(expected[key]) !== JSON.stringify(actual[key])) {
diff[key] = { expected: expected[key], actual: actual[key] };
}
});
return diff;
}
}
/**
* TokenManager class that handles logs
*/
class TokenLogger {
constructor(LoggerClass) {
return new LoggerClass('TokenManager');
}
}
/**
* TokenManager class that handles token creation, validation, and decryption.
*/
class TokenManager {
/**
* @param {string} jwtSecret - Secret used for signing JWTs.
* @param {string|number} defaultExpiry - Default token expiry (e.g., '24h').
* @param {string} encryptionKey - Secret key for AES encryption.
*/
constructor(jwtSecret, defaultExpiry = 24, encryptionKey) {
this.jwtService = new JWTService(jwtSecret);
this.encryptionService = new EncryptionService(encryptionKey);
this.logger = new TokenLogger(Logger);
this.defaultExpiry = defaultExpiry;
}
/**
* Creates a JWT token, optionally encrypting the payload.
* @param {any} payload
* @param {boolean} [encode=false] - Whether to encrypt the payload.
* @param {string|number} [expiresIn=defaultExpiry]
* @returns {string}
*/
create(payload, encode = false, expiresIn = this.defaultExpiry) {
try {
if (!payload && payload !== 0) throw new Error('Invalid payload');
const expiry = this._normalizeExpiry(expiresIn);
const data = encode
? this.encryptionService.encrypt(payload)
: typeof payload === 'object' ? payload : { value: payload };
return this.jwtService.sign(data, expiry);
} catch (error) {
this.logger.error('Token creation failed', error);
throw error;
}
}
/**
* Validates a token and checks if payload matches.
* @param {any} expectedPayload
* @param {string} token
* @param {boolean} [decode=false] - Whether to decrypt the token.
* @param {boolean} [ignoreExpiry=false]
* @returns {boolean}
*/
validate(expectedPayload, token, decode = false, ignoreExpiry = false) {
try {
const decoded = this.jwtService.verify(token, ignoreExpiry);
const payload = decode
? this.encryptionService.decrypt(decoded)
: decoded;
const isMatch = PayloadComparator.match(expectedPayload, payload);
if (!isMatch) {
this.logger.debug('Payload mismatch', {
expected: expectedPayload,
actual: payload,
diff: PayloadComparator.diff(expectedPayload, payload),
});
}
return isMatch;
} catch (error) {
this.logger.error('Token validation failed', { error: error.message });
return false;
}
}
/**
* Decodes and decrypts token payload if necessary.
* @param {string} token
* @returns {any|null}
*/
decodePayload(token) {
try {
const decoded = this.jwtService.verify(token);
return decoded?.encrypted
? this.encryptionService.decrypt(decoded)
: decoded;
} catch (error) {
this.logger.error('Decode failed', { error: error.message });
return null;
}
}
/**
* Extracts raw payload from a token without verification.
* @param {string} token
* @returns {any|null}
*/
extractPayload(token) {
try {
const decoded = this.jwtService.decode(token);
if (!decoded || decoded.encrypted) return null;
return decoded.payload ?? decoded.value ?? decoded;
} catch (err) {
this.logger.error('Payload extraction failed', { error: err.message });
return null;
}
}
/**
* Normalizes expiration format.
* @private
* @param {string|number} exp
* @returns {string|number}
*/
_normalizeExpiry(exp) {
if (typeof exp === 'number' || (typeof exp === 'string' && /^(\d+|\d+\.\d+)[smhd]?$/i.test(exp))) {
return exp;
}
this.logger.warn(`Invalid expiry "${exp}", defaulting to "24h"`);
return '24h';
}
}
module.exports = TokenManager;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mirotalk",
"version": "1.5.03",
"version": "1.5.04",
"description": "A free WebRTC browser-based video call",
"main": "server.js",
"scripts": {
@@ -61,7 +61,7 @@
"jsonwebtoken": "^9.0.2",
"js-yaml": "^4.1.0",
"nodemailer": "^6.10.1",
"openai": "^4.95.0",
"openai": "^4.95.1",
"qs": "^6.14.0",
"socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.1",
+12
View File
@@ -3780,6 +3780,18 @@ img.has-shadow {
overflow: auto;
}
#lastRoomContainer {
display: inline-flex;
max-width: 100%;
flex-wrap: wrap;
}
#lastRoom {
word-break: break-word;
white-space: normal;
overflow-wrap: break-word;
}
/* #roomName {
text-align: center;
justify-content: center;
+1 -1
View File
@@ -73,7 +73,7 @@ let brand = {
},
about: {
imageUrl: '../images/mirotalk-logo.gif',
title: 'WebRTC P2P v1.5.03',
title: 'WebRTC P2P v1.5.04',
html: `
<button
id="support-button"
+3 -3
View File
@@ -15,7 +15,7 @@
* @license For commercial use or closed source, contact us at license.mirotalk@gmail.com or purchase directly from CodeCanyon
* @license CodeCanyon: https://codecanyon.net/item/mirotalk-p2p-webrtc-realtime-video-conferences/38376661
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
* @version 1.5.03
* @version 1.5.04
*
*/
@@ -940,7 +940,7 @@ function getRoomId() {
let queryRoomId = filterXSS(qs.get('room'));
// skip /join/
let roomId = queryRoomId ? queryRoomId : window.location.pathname.substring(6);
let roomId = queryRoomId ? queryRoomId : window.location.pathname.split('/join/')[1];
// if not specified room id, create one random
if (roomId == '') {
@@ -11158,7 +11158,7 @@ function showAbout() {
Swal.fire({
background: swBg,
position: 'center',
title: brand.about?.title && brand.about.title.trim() !== '' ? brand.about.title : 'WebRTC P2P v1.5.03',
title: brand.about?.title && brand.about.title.trim() !== '' ? brand.about.title : 'WebRTC P2P v1.5.04',
imageUrl: brand.about?.imageUrl && brand.about.imageUrl.trim() !== '' ? brand.about.imageUrl : images.about,
customClass: { image: 'img-about' },
html: `
+1 -1
View File
@@ -181,8 +181,8 @@ if (roomName) {
const lastRoomContainer = document.getElementById('lastRoomContainer');
const lastRoom = document.getElementById('lastRoom');
const lastRoomName = window.localStorage.lastRoom ? window.localStorage.lastRoom : '';
if (lastRoomContainer && lastRoom && lastRoomName) {
lastRoomContainer.style.display = 'inline-flex';
lastRoom.setAttribute('href', '/join/' + lastRoomName);
lastRoom.innerText = lastRoomName;
}