@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.
712 lines (538 loc) • 25 kB
JavaScript
const fs = require('fs');
const path = require('path');
const bcryptjs = require('bcryptjs');
const packageJson = require('../package.json');
const {
logDebugMessageToConsole
} = require('../utils/logger');
const {
getImagesDirectoryPath, getDataDirectoryPath, getPublicDirectoryPath, getDatabaseFilePath, getVideosDirectoryPath
} = require('../utils/paths');
const {
getNodeSettings, setNodeSettings, getNodeIdentification, performNodeIdentification, getIsDockerEnvironment, websocketChatBroadcast, getExternalVideosBaseUrl
} = require('../utils/helpers');
const {
isNodeNameValid, isNodeAboutValid, isNodeIdValid, isUsernameValid, isPasswordValid, isPublicNodeProtocolValid, isPublicNodeAddressValid,
isPortValid, isCloudflareCredentialsValid, isBooleanValid, isDatabaseConfigValid, isStorageConfigValid
} = require('../utils/validators');
const {
indexer_doNodePersonalizeNodeNameUpdate, indexer_doNodePersonalizeNodeAboutUpdate, indexer_doNodePersonalizeNodeIdUpdate,
indexer_doNodeExternalNetworkUpdate
} = require('../utils/indexer-communications');
const {
cloudflare_setCdnConfiguration, cloudflare_resetCdn, cloudflare_purgeNodeImages, cloudflare_purgeNodePage,
cloudflare_purgeAllWatchPages, cloudflare_addCdnDnsRecord, cloudflare_purgeEntireCache
} = require('../utils/cloudflare-communications');
const {
submitDatabaseWriteJob, performDatabaseReadJob_ALL, clearDatabase
} = require('../utils/database');
function root_GET() {
const nodeSettings = getNodeSettings();
nodeSettings.version = packageJson.version;
return { isError: false, nodeSettings: nodeSettings };
}
function avatar_GET() {
const customAvatarDirectoryPath = path.join(path.join(getDataDirectoryPath(), 'images'), 'avatar.png');
const defaultAvatarDirectoryPath = path.join(path.join(getPublicDirectoryPath(), 'images'), 'avatar.png');
let avatarFilePath;
if (fs.existsSync(customAvatarDirectoryPath)) {
avatarFilePath = customAvatarDirectoryPath;
}
else if (fs.existsSync(defaultAvatarDirectoryPath)) {
avatarFilePath = defaultAvatarDirectoryPath;
}
if (avatarFilePath != null) {
const fileStream = fs.createReadStream(avatarFilePath);
return fileStream;
}
else {
return null;
}
}
async function avatar_POST(iconFile, avatarFile) {
const iconSourceFilePath = path.join(getImagesDirectoryPath(), iconFile.filename);
const avatarSourceFilePath = path.join(getImagesDirectoryPath(), avatarFile.filename);
const iconDestinationFilePath = path.join(getImagesDirectoryPath(), 'icon.png');
const avatarDestinationFilePath = path.join(getImagesDirectoryPath(), 'avatar.png');
fs.renameSync(iconSourceFilePath, iconDestinationFilePath);
fs.renameSync(avatarSourceFilePath, avatarDestinationFilePath);
cloudflare_purgeNodeImages();
await submitDatabaseWriteJob('UPDATE videos SET is_index_outdated = CASE WHEN is_indexed = ? THEN ? ELSE is_index_outdated END', [true, true]);
return { isError: false };
}
function banner_GET(req, res) {
const customBannerDirectoryPath = path.join(path.join(getDataDirectoryPath(), 'images'), 'banner.png');
const defaultBannerDirectoryPath = path.join(path.join(getPublicDirectoryPath(), 'images'), 'banner.png');
let bannerFilePath;
if (fs.existsSync(customBannerDirectoryPath)) {
bannerFilePath = customBannerDirectoryPath;
}
else if (fs.existsSync(defaultBannerDirectoryPath)) {
bannerFilePath = defaultBannerDirectoryPath;
}
if (bannerFilePath != null) {
const fileStream = fs.createReadStream(bannerFilePath);
return fileStream;
}
else {
return null;
}
}
function banner_POST(bannerFile) {
const bannerSourceFilePath = path.join(getImagesDirectoryPath(), bannerFile.filename);
const bannerDestinationFilePath = path.join(getImagesDirectoryPath(), 'banner.png');
fs.renameSync(bannerSourceFilePath, bannerDestinationFilePath);
cloudflare_purgeNodeImages();
return { isError: false };
}
async function personalizeNodeName_POST(nodeName) {
if (isNodeNameValid(nodeName)) {
const nodeSettings = getNodeSettings();
nodeSettings.nodeName = nodeName;
setNodeSettings(nodeSettings);
const indexedVideos = await performDatabaseReadJob_ALL('SELECT * FROM videos WHERE is_indexed = ?', [true]);
if (indexedVideos.length > 0) {
await performNodeIdentification();
const nodeIdentification = getNodeIdentification();
const moarTubeTokenProof = nodeIdentification.moarTubeTokenProof;
const indexerResponseData = await indexer_doNodePersonalizeNodeNameUpdate(moarTubeTokenProof, nodeName);
if (indexerResponseData.isError) {
throw new Error(indexerResponseData.message);
}
else {
cloudflare_purgeNodePage();
}
}
return { isError: false };
}
else {
throw new Error('invalid parameters');
}
}
async function personalizeNodeAbout_POST(nodeAbout) {
if (isNodeAboutValid(nodeAbout)) {
const nodeSettings = getNodeSettings();
nodeSettings.nodeAbout = nodeAbout;
setNodeSettings(nodeSettings);
const indexedVideos = await performDatabaseReadJob_ALL('SELECT * FROM videos WHERE is_indexed = ?', [true]);
if (indexedVideos.length > 0) {
await performNodeIdentification();
const nodeIdentification = getNodeIdentification();
const moarTubeTokenProof = nodeIdentification.moarTubeTokenProof;
const indexerResponseData = await indexer_doNodePersonalizeNodeAboutUpdate(moarTubeTokenProof, nodeAbout);
if (indexerResponseData.isError) {
throw new Error(indexerResponseData.message);
}
else {
cloudflare_purgeNodePage();
}
}
return { isError: false };
}
else {
throw new Error('invalid parameters');
}
}
async function personalizeNodeId_POST(nodeId) {
if (isNodeIdValid(nodeId)) {
const indexedVideos = await performDatabaseReadJob_ALL('SELECT * FROM videos WHERE is_indexed = ?', [true]);
if (indexedVideos.length > 0) {
await performNodeIdentification();
const nodeIdentification = getNodeIdentification();
const moarTubeTokenProof = nodeIdentification.moarTubeTokenProof;
const indexerResponseData = await indexer_doNodePersonalizeNodeIdUpdate(moarTubeTokenProof, nodeId);
if (indexerResponseData.isError) {
throw new Error(indexerResponseData.message);
}
else {
cloudflare_purgeNodePage();
}
}
const nodeSettings = getNodeSettings();
nodeSettings.nodeId = nodeId;
setNodeSettings(nodeSettings);
return { isError: false };
}
else {
throw new Error('invalid parameters');
}
}
function secure_POST(isSecure, keyFile, certFile, caFiles) {
if (isSecure) {
if (keyFile == null || keyFile.length !== 1) {
return { isError: true, message: 'private key file is missing' };
}
else if (certFile == null || certFile.length !== 1) {
return { isError: true, message: 'cert file is missing' };
}
else {
logDebugMessageToConsole('switching node to HTTPS mode', null, null);
const nodeSettings = getNodeSettings();
nodeSettings.isSecure = true;
setNodeSettings(nodeSettings);
return { isError: false };
}
}
else {
logDebugMessageToConsole('switching node to HTTP mode', null, null);
const nodeSettings = getNodeSettings();
nodeSettings.isSecure = false;
setNodeSettings(nodeSettings);
return { isError: false };
}
}
async function cloudflareConfigure_POST(cloudflareEmailAddress, cloudflareZoneId, cloudflareGlobalApiKey) {
const isValid = await isCloudflareCredentialsValid(cloudflareEmailAddress, cloudflareZoneId, cloudflareGlobalApiKey);
if (isValid) {
const nodeSettings = getNodeSettings();
const storageConfig = nodeSettings.storageConfig;
await cloudflare_resetCdn(cloudflareEmailAddress, cloudflareZoneId, cloudflareGlobalApiKey);
await cloudflare_setCdnConfiguration(cloudflareEmailAddress, cloudflareZoneId, cloudflareGlobalApiKey);
await cloudflare_addCdnDnsRecord(cloudflareEmailAddress, cloudflareZoneId, cloudflareGlobalApiKey, storageConfig);
await cloudflare_purgeEntireCache(cloudflareEmailAddress, cloudflareZoneId, cloudflareGlobalApiKey);
nodeSettings.isCloudflareCdnEnabled = true;
nodeSettings.cloudflareEmailAddress = cloudflareEmailAddress;
nodeSettings.cloudflareZoneId = cloudflareZoneId;
nodeSettings.cloudflareGlobalApiKey = cloudflareGlobalApiKey;
setNodeSettings(nodeSettings);
return { isError: false };
}
else {
throw new Error('could not validate the Cloudflare credentials');
}
}
async function cloudflareClear_POST() {
const nodeSettings = getNodeSettings();
if (nodeSettings.isCloudflareCdnEnabled) {
const cloudflareEmailAddress = nodeSettings.cloudflareEmailAddress;
const cloudflareZoneId = nodeSettings.cloudflareZoneId;
const cloudflareGlobalApiKey = nodeSettings.cloudflareGlobalApiKey;
await cloudflare_resetCdn(cloudflareEmailAddress, cloudflareZoneId, cloudflareGlobalApiKey);
await cloudflare_purgeEntireCache(cloudflareEmailAddress, cloudflareZoneId, cloudflareGlobalApiKey);
nodeSettings.isCloudflareCdnEnabled = false;
nodeSettings.cloudflareEmailAddress = '';
nodeSettings.cloudflareZoneId = '';
nodeSettings.cloudflareGlobalApiKey = '';
setNodeSettings(nodeSettings);
}
return { isError: false };
}
async function cloudflareTurnstileConfigure_POST(cloudflareTurnstileSiteKey, cloudflareTurnstileSecretKey) {
const nodeSettings = getNodeSettings();
nodeSettings.isCloudflareTurnstileEnabled = true;
nodeSettings.cloudflareTurnstileSiteKey = cloudflareTurnstileSiteKey;
nodeSettings.cloudflareTurnstileSecretKey = cloudflareTurnstileSecretKey;
setNodeSettings(nodeSettings);
websocketChatBroadcast({ eventName: 'information', videoId: 'all', cloudflareTurnstileSiteKey: cloudflareTurnstileSiteKey });
cloudflare_purgeAllWatchPages();
return { isError: false };
}
async function cloudflareTurnstileConfigureClear_POST() {
const nodeSettings = getNodeSettings();
nodeSettings.isCloudflareTurnstileEnabled = false;
nodeSettings.cloudflareTurnstileSiteKey = '';
nodeSettings.cloudflareTurnstileSecretKey = '';
setNodeSettings(nodeSettings);
websocketChatBroadcast({ eventName: 'information', videoId: 'all', cloudflareTurnstileSiteKey: '' });
cloudflare_purgeAllWatchPages();
return { isError: false };
}
function commentsToggle_POST(isCommentsEnabled) {
if (isBooleanValid(isCommentsEnabled)) {
const nodeSettings = getNodeSettings();
nodeSettings.isCommentsEnabled = isCommentsEnabled;
setNodeSettings(nodeSettings);
return { isError: false };
}
else {
return { isError: true, message: 'invalid parameters' };
}
}
async function databaseConfigToggle_POST(databaseConfig) {
if (isDatabaseConfigValid(databaseConfig)) {
try {
const { Sequelize } = require('sequelize');
const databaseDialect = databaseConfig.databaseDialect;
let sequelize;
if (databaseDialect === 'sqlite') {
sequelize = new Sequelize({
dialect: 'sqlite',
storage: getDatabaseFilePath(),
logging: false
});
}
else if (databaseDialect === 'postgres') {
const postgresConfig = databaseConfig.postgresConfig;
const databaseName = postgresConfig.databaseName;
const username = postgresConfig.username;
const password = postgresConfig.password;
const host = postgresConfig.host;
const port = postgresConfig.port;
sequelize = new Sequelize(databaseName, username, password, {
dialect: 'postgres',
host: host,
port: port,
logging: false
});
}
await sequelize.authenticate();
await sequelize.close();
const nodeSettings = getNodeSettings();
nodeSettings.databaseConfig = databaseConfig;
setNodeSettings(nodeSettings);
process.send({ cmd: 'restart_database', databaseDialect: databaseDialect });
return { isError: false };
}
catch (error) {
throw error;
}
}
else {
throw new Error('invalid parameters');
}
}
async function storageConfigToggle_POST(storageConfig) {
if (isStorageConfigValid(storageConfig)) {
try {
const nodeSettings = getNodeSettings();
if (nodeSettings.isCloudflareCdnEnabled) {
const cloudflareEmailAddress = nodeSettings.cloudflareEmailAddress;
const cloudflareZoneId = nodeSettings.cloudflareZoneId;
const cloudflareGlobalApiKey = nodeSettings.cloudflareGlobalApiKey;
await cloudflare_addCdnDnsRecord(cloudflareEmailAddress, cloudflareZoneId, cloudflareGlobalApiKey, storageConfig);
await cloudflare_purgeEntireCache(cloudflareEmailAddress, cloudflareZoneId, cloudflareGlobalApiKey);
}
nodeSettings.storageConfig = storageConfig;
setNodeSettings(nodeSettings);
return { isError: false };
}
catch (error) {
throw error;
}
}
else {
throw new Error('invalid parameters');
}
}
function likesToggle_POST(isLikesEnabled) {
if (isBooleanValid(isLikesEnabled)) {
const nodeSettings = getNodeSettings();
nodeSettings.isLikesEnabled = isLikesEnabled;
setNodeSettings(nodeSettings);
return { isError: false };
}
else {
return { isError: true, message: 'invalid parameters' };
}
}
function dislikesToggle_POST(isDislikesEnabled) {
if (isBooleanValid(isDislikesEnabled)) {
const nodeSettings = getNodeSettings();
nodeSettings.isDislikesEnabled = isDislikesEnabled;
setNodeSettings(nodeSettings);
return { isError: false };
}
else {
return { isError: true, message: 'invalid parameters' };
}
}
function reportsToggle_POST(isReportsEnabled) {
if (isBooleanValid(isReportsEnabled)) {
const nodeSettings = getNodeSettings();
nodeSettings.isReportsEnabled = isReportsEnabled;
setNodeSettings(nodeSettings);
return { isError: false };
}
else {
return { isError: true, message: 'invalid parameters' };
}
}
function liveChatToggle_POST(isLiveChatEnabled) {
if (isBooleanValid(isLiveChatEnabled)) {
const nodeSettings = getNodeSettings();
nodeSettings.isLiveChatEnabled = isLiveChatEnabled;
setNodeSettings(nodeSettings);
return { isError: false };
}
else {
return { isError: true, message: 'invalid parameters' };
}
}
function account_POST(username, password) {
if (isUsernameValid(username) && isPasswordValid(password)) {
const usernameHash = encodeURIComponent(Buffer.from(bcryptjs.hashSync(username, 10), 'utf8').toString('base64'));
const passwordHash = encodeURIComponent(Buffer.from(bcryptjs.hashSync(password, 10), 'utf8').toString('base64'));
const nodeSettings = getNodeSettings();
nodeSettings.username = usernameHash;
nodeSettings.password = passwordHash;
setNodeSettings(nodeSettings);
return { isError: false };
}
else {
return { isError: true, message: 'invalid username and/or password' };
}
}
function networkInternal_POST(listeningNodePort) {
if (getIsDockerEnvironment()) {
return { isError: true, message: 'This node cannot change listening ports because it is running inside of a docker container.' };
}
else {
if (isPortValid(listeningNodePort)) {
logDebugMessageToConsole('switching node to HTTPS mode', null, null);
const nodeSettings = getNodeSettings();
nodeSettings.nodeListeningPort = listeningNodePort;
setNodeSettings(nodeSettings);
return { isError: false };
}
else {
return { isError: true, message: 'invalid parameters' };
}
}
}
async function networkExternal_POST(publicNodeProtocol, publicNodeAddress, publicNodePort) {
if (isPublicNodeProtocolValid(publicNodeProtocol) && isPublicNodeAddressValid(publicNodeAddress) && isPortValid(publicNodePort)) {
const indexedVideos = await performDatabaseReadJob_ALL('SELECT * FROM videos WHERE is_indexed = ?', [true]);
if (indexedVideos.length > 0) {
await performNodeIdentification();
const nodeIdentification = getNodeIdentification();
const moarTubeTokenProof = nodeIdentification.moarTubeTokenProof;
const indexerResponseData = await indexer_doNodeExternalNetworkUpdate(moarTubeTokenProof, publicNodeProtocol, publicNodeAddress, publicNodePort);
if (indexerResponseData.isError) {
throw new Error(indexerResponseData.message);
}
}
const nodeSettings = getNodeSettings();
if (nodeSettings.storageConfig.storageMode === 'filesystem') {
const externalVideosBaseUrl = getExternalVideosBaseUrl();
const videosDirectoryPath = getVideosDirectoryPath();
const videos = await performDatabaseReadJob_ALL('SELECT video_id, outputs FROM videos', []);
for (const video of videos) {
const videoId = video.video_id;
const outputs = JSON.parse(video.outputs);
if (outputs.m3u8.length > 0) {
const masterManifestPath = path.join(videosDirectoryPath, videoId, 'adaptive', 'm3u8', 'manifest-master.m3u8');
if (fs.existsSync(masterManifestPath)) {
await performUpdate(masterManifestPath, externalVideosBaseUrl)
}
for (const resolution of outputs.m3u8) {
const manifestPath = path.join(videosDirectoryPath, videoId, 'adaptive', 'm3u8', 'manifest-' + resolution + '.m3u8');
if (fs.existsSync(manifestPath)) {
await performUpdate(manifestPath, externalVideosBaseUrl)
}
}
}
}
}
async function performUpdate(manifestPath, externalVideosBaseUrl) {
const oldManifest = fs.readFileSync(manifestPath, "utf-8");
const newManifest = oldManifest.replace(/(https?:\/\/).*?(\/external\/)/g, externalVideosBaseUrl + "$2");
fs.writeFileSync(manifestPath, newManifest, "utf-8");
}
nodeSettings.publicNodeProtocol = publicNodeProtocol;
nodeSettings.publicNodeAddress = publicNodeAddress;
nodeSettings.publicNodePort = publicNodePort;
setNodeSettings(nodeSettings);
return { isError: false };
}
else {
return { isError: true, message: 'invalid parameters' };
}
}
async function importDatabase_POST(databaseFile) {
if (databaseFile == null || databaseFile.length !== 1) {
return { isError: true, message: 'database file is missing' };
}
else {
databaseFile = databaseFile[0];
try {
const database = JSON.parse(databaseFile.buffer.toString());
await clearDatabase();
for (const table of database) {
const rows = table.rows;
const tableName = table.tableName;
if (rows.length > 0) {
const columns = Object.keys(rows[0]);
const placeholders = rows.map(() => `(${columns.map(() => '?').join(', ')})`).join(', ');
const values = rows.flatMap(row => {
return Object.values(row);
});
const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES ${placeholders}`;
await submitDatabaseWriteJob(query, values);
}
}
console.log('database imported successfully');
return { isError: false };
}
catch (error) {
throw error;
}
}
}
async function exportDatabase_GET() {
const videos = await performDatabaseReadJob_ALL('SELECT * FROM videos', []);
const comments = await performDatabaseReadJob_ALL('SELECT * FROM comments', []);
const videoReports = await performDatabaseReadJob_ALL('SELECT * FROM videoreports', []);
const commentReports = await performDatabaseReadJob_ALL('SELECT * FROM commentreports', []);
const videoReportsArchives = await performDatabaseReadJob_ALL('SELECT * FROM videoreportsarchives', []);
const commentReportsArchives = await performDatabaseReadJob_ALL('SELECT * FROM commentreportsarchives', []);
const liveChatMessages = await performDatabaseReadJob_ALL('SELECT * FROM livechatmessages', []);
const cryptoWalletAddresses = await performDatabaseReadJob_ALL('SELECT * FROM cryptowalletaddresses', []);
const links = await performDatabaseReadJob_ALL('SELECT * FROM links', []);
const database = [
{ rows: videos, tableName: 'videos' },
{ rows: comments, tableName: 'comments' },
{ rows: videoReports, tableName: 'videoreports' },
{ rows: commentReports, tableName: 'commentreports' },
{ rows: videoReportsArchives, tableName: 'videoreportsarchives' },
{ rows: commentReportsArchives, tableName: 'commentreportsarchives' },
{ rows: liveChatMessages, tableName: 'livechatmessages' },
{ rows: cryptoWalletAddresses, tableName: 'cryptowalletaddresses' },
{ rows: links, tableName: 'links' }
];
/*
1) Delete all rows with an "id" column as it was generated by the database engine upon insertion of the row.
Let the database engine generate a new "id" column during an import operation.
Note: The "id" column isn't used for anything.
Note: SQLite re-uses "id" numbers that were previously used by removed rows - Postgres does not.
2) Convert all 1/0 boolean to true/false boolean. The latter is agnostic for both SQLite and Postgres,
otherwise a cast exception will throw when importing an SQLite database export into a Postgres database.
*/
for(const table of database) {
table.rows.forEach(row => {
delete row.id;
for (const column in row) {
if (column.startsWith("is_") && (row[column] === 0 || row[column] === 1)) {
row[column] = Boolean(row[column]);
}
}
});
}
return { isError: false, database: database };
}
module.exports = {
root_GET,
avatar_GET,
avatar_POST,
banner_GET,
banner_POST,
personalizeNodeName_POST,
personalizeNodeAbout_POST,
personalizeNodeId_POST,
secure_POST,
cloudflareConfigure_POST,
cloudflareTurnstileConfigure_POST,
cloudflareTurnstileConfigureClear_POST,
cloudflareClear_POST,
commentsToggle_POST,
likesToggle_POST,
dislikesToggle_POST,
reportsToggle_POST,
liveChatToggle_POST,
account_POST,
networkInternal_POST,
networkExternal_POST,
databaseConfigToggle_POST,
storageConfigToggle_POST,
exportDatabase_GET,
importDatabase_POST
};