@moartube/moartube-node
Version:
A free, open-source, self-hosted, anonymous, decentralized video/live stream platform. Scalable via Cloudflare, works in the cloud or from home WiFi.
448 lines (364 loc) • 22.9 kB
JavaScript
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const webSocket = require('ws');
const httpTerminator = require('http-terminator');
const sanitizeHtml = require('sanitize-html');
const cluster = require('cluster');
const {
logDebugMessageToConsole
} = require('./logger');
const {
getCertificatesDirectoryPath
} = require("./paths");
const {
getNodeSettings, getAuthenticationStatus, websocketNodeBroadcast, websocketChatBroadcast
} = require("./helpers");
const {
stoppedPublishVideoUploading, stoppingPublishVideoUploading
} = require("./trackers/publish-video-uploading-tracker");
const {
isVideoIdValid, isChatMessageContentValid, isCloudflareTurnstileTokenValid, isTimestampValid
} = require('./validators');
const {
performDatabaseReadJob_GET, submitDatabaseWriteJob
} = require('./database');
const {
cloudflare_validateTurnstileToken
} = require('../utils/cloudflare-communications');
let httpServerWrapper;
let app;
function initializeHttpServer(value) {
if (app == null) {
app = value;
}
const nodeSettings = getNodeSettings();
let httpServer;
if (nodeSettings.isSecure) {
if (fs.existsSync(getCertificatesDirectoryPath())) {
let key = '';
let cert = '';
let ca = [];
fs.readdirSync(getCertificatesDirectoryPath()).forEach(fileName => {
if (fileName === 'private_key.pem') {
key = fs.readFileSync(path.join(getCertificatesDirectoryPath(), 'private_key.pem'), 'utf8');
}
else if (fileName === 'certificate.pem') {
cert = fs.readFileSync(path.join(getCertificatesDirectoryPath(), 'certificate.pem'), 'utf8');
}
else {
const caFile = fs.readFileSync(path.join(getCertificatesDirectoryPath(), fileName), 'utf8');
ca.push(caFile);
}
});
if (key === '') {
throw new Error('private key not found for HTTPS server');
}
else if (cert === '') {
throw new Error('certificate not found for HTTPS server');
}
else {
const sslCredentials = {
key: key,
cert: cert,
ca: ca
};
logDebugMessageToConsole('MoarTube Node worker ' + cluster.worker.id + ' is entering secure HTTPS mode', null, null);
httpServer = https.createServer(sslCredentials, app);
}
}
else {
throw new Error('certificate directory not found for HTTPS server');
}
}
else {
logDebugMessageToConsole('MoarTube Node worker ' + cluster.worker.id + ' is entering non-secure HTTP mode', null, null);
httpServer = http.createServer(app);
}
httpServer.requestTimeout = 300000;
httpServer.keepAliveTimeout = 10000;
httpServer.listen(nodeSettings.nodeListeningPort, function () {
logDebugMessageToConsole('MoarTube Node worker ' + cluster.worker.id + ' is listening on port ' + nodeSettings.nodeListeningPort, null, null);
const websocketServer = new webSocket.Server({
noServer: true,
perMessageDeflate: false
});
websocketServer.on('connection', function connection(ws, req) {
logDebugMessageToConsole('MoarTube Client websocket connected', null, null);
let ip = req.headers['CF-Connecting-IP'];
if (ip == null) {
ip = req.socket.remoteAddress;
}
ws.on('close', () => {
logDebugMessageToConsole('MoarTube Client websocket disconnected', null, null);
});
ws.on('message', async (message) => {
const parsedMessage = JSON.parse(message.toString());
const jwtToken = parsedMessage.jwtToken;
if (jwtToken != null) {
// attempting a websocket message that expects authentication
const isAuthenticated = await getAuthenticationStatus(jwtToken);
if (isAuthenticated) {
if (parsedMessage.eventName === 'ping') {
//logDebugMessageToConsole('received ping from client', null, null);
if (ws.socketType === 'moartube_client') {
//logDebugMessageToConsole('sending pong to client', null, null);
ws.send(JSON.stringify({ eventName: 'pong' }));
}
}
else if (parsedMessage.eventName === 'register') {
const socketType = parsedMessage.socketType;
if (socketType === 'moartube_client') {
ws.socketType = socketType;
ws.send(JSON.stringify({ eventName: 'registered' }));
}
}
else if (parsedMessage.eventName === 'echo') {
if (parsedMessage.data.eventName === 'video_status') {
const payload = parsedMessage.data.payload;
const type = payload.type;
const videoId = payload.videoId;
if (isVideoIdValid(videoId, false)) {
if (type === 'importing') {
const progress = payload.progress;
websocketNodeBroadcast(parsedMessage);
}
else if (type === 'imported') {
const lengthTimestamp = payload.lengthTimestamp;
websocketNodeBroadcast(parsedMessage);
}
else if (type === 'publishing') {
const format = payload.format;
const resolution = payload.resolution;
const progress = payload.progress;
websocketNodeBroadcast(parsedMessage);
}
else if (type === 'published') {
const lengthTimestamp = payload.lengthTimestamp;
const lengthSeconds = payload.lengthSeconds;
websocketNodeBroadcast(parsedMessage);
}
else if (type === 'streaming') {
const lengthTimestamp = payload.lengthTimestamp;
const bandwidth = payload.bandwidth;
websocketNodeBroadcast(parsedMessage);
}
else if (type === 'importing_stopping') {
websocketNodeBroadcast(parsedMessage);
}
else if (type === 'importing_stopped') {
websocketNodeBroadcast(parsedMessage);
}
else if (type === 'publishing_stopping') {
stoppingPublishVideoUploading(videoId, parsedMessage);
}
else if (type === 'publishing_stopped') {
stoppedPublishVideoUploading(videoId, parsedMessage)
}
else if (type === 'streaming_stopping') {
websocketNodeBroadcast(parsedMessage);
}
else if (type === 'streaming_stopped') {
websocketNodeBroadcast(parsedMessage);
}
else if (type === 'finalized') {
websocketNodeBroadcast(parsedMessage);
}
}
}
else if (parsedMessage.data.eventName === 'video_data') {
websocketNodeBroadcast(parsedMessage);
}
}
}
}
else {
if (parsedMessage.eventName === 'register') {
const socketType = parsedMessage.socketType;
if (socketType === 'node_peer') {
ws.socketType = socketType;
const nodeSettings = getNodeSettings();
ws.send(JSON.stringify({ eventName: 'registered' }));
ws.send(JSON.stringify({ eventName: 'information', cloudflareTurnstileSiteKey: nodeSettings.cloudflareTurnstileSiteKey }));
}
else {
ws.send(JSON.stringify({ eventName: 'error', errorType: 'register', message: 'invalid socket type' }));
ws.close();
}
}
else if (parsedMessage.eventName === 'chat') {
if (ws.socketType != null) {
if (parsedMessage.type === 'join') {
const videoId = parsedMessage.videoId;
if (isVideoIdValid(videoId, false)) {
ws.videoId = videoId;
let liveChatUsername = '';
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
for (let i = 0; i < 8; i++) {
liveChatUsername += chars[Math.floor(Math.random() * chars.length)];
}
ws.liveChatUsername = liveChatUsername;
ws.liveChatUsernameColorCode = ('000000' + Math.floor(Math.random() * 16777215).toString(16)).slice(-6);
ws.rateLimiter = {
timestamps: [],
rateLimitTimestamp: 0,
rateLimitLevel: -1,
isRateLimited: false
};
ws.send(JSON.stringify({ eventName: 'joined', liveChatUsername: ws.liveChatUsername, liveChatUsernameColorCode: ws.liveChatUsernameColorCode }));
}
else {
ws.send(JSON.stringify({ eventName: 'error', errorType: 'join', message: 'invalid join parameters' }));
ws.close();
}
}
else if (parsedMessage.type === 'message') {
const videoId = parsedMessage.videoId;
const chatMessageContent = sanitizeHtml(parsedMessage.chatMessageContent, { allowedTags: [], allowedAttributes: {} });
const cloudflareTurnstileToken = parsedMessage.cloudflareTurnstileToken;
const sentTimestamp = parsedMessage.sentTimestamp;
if (isVideoIdValid(videoId, false) && isChatMessageContentValid(chatMessageContent) && isTimestampValid(sentTimestamp) && isCloudflareTurnstileTokenValid(cloudflareTurnstileToken, true)) {
let errorMessage;
try {
const nodeSettings = getNodeSettings();
if (!nodeSettings.isLiveChatEnabled) {
errorMessage = 'live chat is currently disabled';
}
else if (nodeSettings.isCloudflareTurnstileEnabled) {
if (cloudflareTurnstileToken.length === 0) {
errorMessage = 'human verification was enabled on this MoarTube Node, please refresh your browser';
}
else {
await cloudflare_validateTurnstileToken(cloudflareTurnstileToken, ip);
}
}
else {
const video = await performDatabaseReadJob_GET('SELECT is_live_chat_enabled FROM videos WHERE video_id = ?', [videoId]);
if(video != null) {
const isLiveChatEnabled = video.is_live_chat_enabled ? true : false;
if(!isLiveChatEnabled) {
errorMessage = 'live chat is currently disabled';
}
}
else {
errorMessage = 'this video no longer exists';
}
}
}
catch (error) {
throw error;
}
if (errorMessage == null) {
const rateLimiter = ws.rateLimiter;
const timestamp = Date.now();
const BASE_RATE_LIMIT_PENALTY_MILLISECONDS = 5000;
const BASE_RATE_LIMIT_PENALTY_SECONDS = BASE_RATE_LIMIT_PENALTY_MILLISECONDS / 1000;
const RATE_LIMIT_EAGERNESS_PENALTY_MILLISECONDS = 3000;
if (rateLimiter.isRateLimited) {
const rateLimitThreshold = BASE_RATE_LIMIT_PENALTY_MILLISECONDS + (rateLimiter.rateLimitLevel * BASE_RATE_LIMIT_PENALTY_MILLISECONDS);
if ((timestamp - rateLimiter.rateLimitTimestamp) > rateLimitThreshold) {
if ((timestamp - rateLimiter.rateLimitTimestamp) < (rateLimitThreshold + RATE_LIMIT_EAGERNESS_PENALTY_MILLISECONDS)) {
rateLimiter.rateLimitTimestamp = timestamp;
rateLimiter.rateLimitLevel++;
ws.send(JSON.stringify({ eventName: 'limited', rateLimitSeconds: BASE_RATE_LIMIT_PENALTY_SECONDS + (rateLimiter.rateLimitLevel * BASE_RATE_LIMIT_PENALTY_SECONDS) }));
}
else {
rateLimiter.isRateLimited = false;
rateLimiter.rateLimitLevel = -1;
}
}
else {
return;
}
}
if (rateLimiter.timestamps.length < 3) {
rateLimiter.timestamps.push(timestamp);
}
else if (rateLimiter.timestamps.length === 3) {
rateLimiter.timestamps.shift();
rateLimiter.timestamps.push(timestamp);
}
if (rateLimiter.timestamps.length === 3) {
const firstTimestamp = rateLimiter.timestamps[0];
const lastTimestamp = rateLimiter.timestamps[rateLimiter.timestamps.length - 1];
const timeEllapsed = lastTimestamp - firstTimestamp;
if (timeEllapsed < BASE_RATE_LIMIT_PENALTY_MILLISECONDS) {
rateLimiter.rateLimitTimestamp = timestamp;
rateLimiter.isRateLimited = true;
rateLimiter.rateLimitLevel++;
ws.send(JSON.stringify({ eventName: 'limited', rateLimitSeconds: BASE_RATE_LIMIT_PENALTY_SECONDS }));
}
}
const liveChatUsername = ws.liveChatUsername;
const liveChatUsernameColorCode = ws.liveChatUsernameColorCode;
ws.rateLimiter = rateLimiter;
websocketChatBroadcast({ eventName: 'message', videoId: videoId, chatMessageContent: chatMessageContent, sentTimestamp: sentTimestamp, liveChatUsername: liveChatUsername, liveChatUsernameColorCode: liveChatUsernameColorCode });
const video = await performDatabaseReadJob_GET('SELECT * FROM videos WHERE video_id = ?', [videoId]);
if (video != null) {
const meta = JSON.parse(video.meta);
const isChatHistoryEnabled = meta.chatSettings.isChatHistoryEnabled;
if (isChatHistoryEnabled) {
const chatHistoryLimit = meta.chatSettings.chatHistoryLimit;
await submitDatabaseWriteJob('INSERT INTO livechatmessages(video_id, username, username_color_hex_code, chat_message, timestamp) VALUES (?, ?, ?, ?, ?)', [videoId, liveChatUsername, liveChatUsernameColorCode, chatMessageContent, timestamp]);
if (chatHistoryLimit !== 0) {
await submitDatabaseWriteJob('DELETE FROM livechatmessages WHERE chat_message_id NOT IN (SELECT chat_message_id FROM livechatmessages where video_id = ? ORDER BY chat_message_id DESC LIMIT ?)', [videoId, chatHistoryLimit]);
}
}
}
}
else {
const liveChatUsername = ws.liveChatUsername;
const liveChatUsernameColorCode = ws.liveChatUsernameColorCode;
ws.send(JSON.stringify({ eventName: 'error', errorType: 'message', message: errorMessage, sentTimestamp: sentTimestamp, liveChatUsername: liveChatUsername, liveChatUsernameColorCode: liveChatUsernameColorCode }));
}
}
else {
ws.send(JSON.stringify({ eventName: 'error', errorType: 'message', message: 'invalid message parameters' }));
ws.close();
}
}
}
}
}
});
});
httpServer.on('upgrade', function upgrade(req, socket, head) {
websocketServer.handleUpgrade(req, socket, head, function done(ws) {
websocketServer.emit('connection', ws, req);
});
});
httpServerWrapper = {
httpServer: httpServer,
websocketServer: websocketServer
};
});
}
async function restartHttpServer() {
//httpServerWrapper.httpServer.closeAllConnections();
httpServerWrapper.websocketServer.clients.forEach(function each(client) {
if (client.readyState === webSocket.OPEN) {
client.close();
}
});
logDebugMessageToConsole('attempting to terminate node', null, null);
const terminator = httpTerminator.createHttpTerminator({ server: httpServerWrapper.httpServer });
logDebugMessageToConsole('termination of node in progress', null, null);
await terminator.terminate();
logDebugMessageToConsole('terminated node', null, null);
httpServerWrapper.websocketServer.close(function () {
logDebugMessageToConsole('node websocketServer closed', null, null);
httpServerWrapper.httpServer.close(async () => {
logDebugMessageToConsole('node web server closed', null, null);
await initializeHttpServer();
});
});
}
function getHttpServerWrapper() {
return httpServerWrapper;
}
module.exports = {
initializeHttpServer,
restartHttpServer,
getHttpServerWrapper
};