const { v4: uuid } = require('uuid'); const fs = require('fs-extra'); const { promisify } = require('util'); const http = require('http'); const https = require('https'); const auth_api = require('./authentication/auth'); const oidc_api = require('./authentication/oidc'); const path = require('path'); const compression = require('compression'); const multer = require('multer'); const express = require("express"); const rateLimit = require('express-rate-limit'); const bodyParser = require("body-parser"); const archiver = require('archiver'); const unzipper = require('unzipper'); const db_api = require('./db'); const utils = require('./utils') const low = require('lowdb') const fetch = globalThis.fetch; const URL = require('url').URL; const CONSTS = require('./consts') const read_last_lines = require('read-last-lines'); const ps = require('ps-node'); const logger = require('./logger'); const config_api = require('./config.js'); const downloader_api = require('./downloader'); const tasks_api = require('./tasks'); const subscriptions_api = require('./subscriptions'); const categories_api = require('./categories'); const twitch_api = require('./twitch'); const youtubedl_api = require('./youtube-dl'); const archive_api = require('./archive'); const files_api = require('./files'); const notifications_api = require('./notifications'); var app = express(); function parseTrustProxySetting(value) { if (value === undefined || value === null) return undefined; if (typeof value !== 'string') return value; const trimmed = value.trim(); if (trimmed === '') return undefined; const lowerValue = trimmed.toLowerCase(); if (lowerValue === 'true') return true; if (lowerValue === 'false') return false; if (/^\d+$/.test(trimmed)) return parseInt(trimmed, 10); if (trimmed.includes(',')) return trimmed.split(',').map(item => item.trim()).filter(item => item !== ''); return trimmed; } function configureExpressTrustProxy() { const trustProxyFromEnv = parseTrustProxySetting(process.env.YTDL_TRUST_PROXY); if (trustProxyFromEnv !== undefined) { app.set('trust proxy', trustProxyFromEnv); logger.info(`Express trust proxy configured from YTDL_TRUST_PROXY: ${JSON.stringify(trustProxyFromEnv)}`); return; } const reverseProxyWhitelist = config_api.getConfigItem('ytdl_reverse_proxy_whitelist'); if (reverseProxyWhitelist && reverseProxyWhitelist.trim() !== '') { const trustedProxies = reverseProxyWhitelist .split(',') .map(item => item.trim()) .filter(item => item !== ''); if (trustedProxies.length > 0) { app.set('trust proxy', trustedProxies); logger.info('Express trust proxy configured from reverse proxy whitelist.'); } } } // database setup const FileSync = require('lowdb/adapters/FileSync'); const adapter = new FileSync('./appdata/db.json'); const db = low(adapter) const users_adapter = new FileSync('./appdata/users.json'); const users_db = low(users_adapter); // env var setup const umask = process.env.YTDL_UMASK; if (umask) process.umask(parseInt(umask)); // check if debug mode let debugMode = process.env.YTDL_MODE === 'debug'; const admin_token = '4241b401-7236-493e-92b5-b72696b9d853'; // logging setup config_api.initialize(); db_api.initialize(db, users_db); auth_api.initialize(db_api); // Set some defaults db.defaults( { playlists: [], files: [], configWriteFlag: false, downloads: {}, subscriptions: [], files_to_db_migration_complete: false, tasks_manager_role_migration_complete: false, archives_migration_complete: false }).write(); users_db.defaults( { users: [], roles: { "admin": { "permissions": [ 'filemanager', 'settings', 'subscriptions', 'sharing', 'advanced_download', 'downloads_manager' ] }, "user": { "permissions": [ 'filemanager', 'subscriptions', 'sharing' ] } } } ).write(); // config values let url = null; let backendPort = null; let useDefaultDownloadingAgent = null; let customDownloadingAgent = null; let allowSubscriptions = null; // other needed values let url_domain = null; let updaterStatus = null; const concurrentStreams = {}; if (debugMode) logger.info('YTDL-Material in debug mode!'); // check if just updated const just_updated = fs.existsSync('restart_update.json'); if (just_updated) { updaterStatus = { updating: false, details: 'Update complete! You are now on ' + CONSTS['CURRENT_VERSION'] } fs.unlinkSync('restart_update.json'); } if (fs.existsSync('restart_general.json')) fs.unlinkSync('restart_general.json'); // updates & starts youtubedl (commented out b/c of repo takedown) // startYoutubeDL(); var validDownloadingAgents = [ 'aria2c', 'avconv', 'axel', 'curl', 'ffmpeg', 'httpie', 'wget' ]; const subscription_timeouts = {}; let version_info = null; if (fs.existsSync('version.json')) { version_info = fs.readJSONSync('version.json'); logger.verbose(`Version info: ${JSON.stringify(version_info, null, 2)}`); } else { version_info = {'type': 'N/A', 'tag': 'N/A', 'commit': 'N/A', 'date': 'N/A'}; } // don't overwrite config if it already happened.. NOT // let alreadyWritten = db.get('configWriteFlag').value(); // checks if config exists, if not, a config is auto generated config_api.configExistsCheck(); setAndLoadConfig(); app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json()); // use passport app.use(auth_api.passport.initialize()); // reverse proxy whitelist app.use(reverseProxyWhitelistMiddleware); // actual functions async function checkMigrations() { // 4.1->4.2 migration const simplified_db_migration_complete = db.get('simplified_db_migration_complete').value(); if (!simplified_db_migration_complete) { logger.info('Beginning migration: 4.1->4.2+') let success = await simplifyDBFileStructure(); success = success && await files_api.addMetadataPropertyToDB('view_count'); success = success && await files_api.addMetadataPropertyToDB('description'); success = success && await files_api.addMetadataPropertyToDB('height'); success = success && await files_api.addMetadataPropertyToDB('abr'); // sets migration to complete db.set('simplified_db_migration_complete', true).write(); if (success) { logger.info('4.1->4.2+ migration complete!'); } else { logger.error('Migration failed: 4.1->4.2+'); } } const new_db_system_migration_complete = db.get('new_db_system_migration_complete').value(); if (!new_db_system_migration_complete) { logger.info('Beginning migration: 4.2->4.3+') let success = await db_api.importJSONToDB(db.value(), users_db.value()); await tasks_api.setupTasks(); // necessary as tasks were not properly initialized at first // sets migration to complete db.set('new_db_system_migration_complete', true).write(); if (success) { logger.info('4.2->4.3+ migration complete!'); } else { logger.error('Migration failed: 4.2->4.3+'); } } const tasks_manager_role_migration_complete = db.get('tasks_manager_role_migration_complete').value(); if (!tasks_manager_role_migration_complete) { logger.info('Checking if tasks manager role permissions exist for admin user...'); const success = await auth_api.changeRolePermissions('admin', 'tasks_manager', 'yes'); if (success) logger.info('Task manager permissions check complete!'); else logger.error('Failed to auto add tasks manager permissions to admin role!'); db.set('tasks_manager_role_migration_complete', true).write(); } const archives_migration_complete = db.get('archives_migration_complete').value(); if (!archives_migration_complete) { logger.info('Checking if archives have been migrated...'); const imported_archives = await archive_api.importArchives(); if (imported_archives) logger.info('Archives migration complete!'); else logger.error('Failed to migrate archives!'); db.set('archives_migration_complete', true).write(); } return true; } async function simplifyDBFileStructure() { // back up db files const old_db_file = fs.readJSONSync('./appdata/db.json'); const old_users_db_file = fs.readJSONSync('./appdata/users.json'); fs.writeJSONSync('appdata/db.old.json', old_db_file); fs.writeJSONSync('appdata/users.old.json', old_users_db_file); // simplify let users = users_db.get('users').value(); for (let i = 0; i < users.length; i++) { const user = users[i]; if (user['files']['video'] !== undefined && user['files']['audio'] !== undefined) { const user_files = user['files']['video'].concat(user['files']['audio']); const user_db_path = users_db.get('users').find({uid: user['uid']}); user_db_path.assign({files: user_files}).write(); } if (user['playlists']['video'] !== undefined && user['playlists']['audio'] !== undefined) { const user_playlists = user['playlists']['video'].concat(user['playlists']['audio']); const user_db_path = users_db.get('users').find({uid: user['uid']}); user_db_path.assign({playlists: user_playlists}).write(); } } if (db.get('files.video').value() !== undefined && db.get('files.audio').value() !== undefined) { const files = db.get('files.video').value().concat(db.get('files.audio').value()); db.assign({files: files}).write(); } if (db.get('playlists.video').value() !== undefined && db.get('playlists.audio').value() !== undefined) { const playlists = db.get('playlists.video').value().concat(db.get('playlists.audio').value()); db.assign({playlists: playlists}).write(); } return true; } // CIDR IP checking utility function ipInCIDR(ip, cidr) { const [range, bits = 32] = cidr.split('/'); const mask = ~(2 ** (32 - bits) - 1); const ipNum = ip.split('.').reduce((int, oct) => (int << 8) + parseInt(oct, 10), 0) >>> 0; const rangeNum = range.split('.').reduce((int, oct) => (int << 8) + parseInt(oct, 10), 0) >>> 0; return (ipNum & mask) === (rangeNum & mask); } // Reverse proxy whitelist middleware function reverseProxyWhitelistMiddleware(req, res, next) { const whitelist = config_api.getConfigItem('ytdl_reverse_proxy_whitelist'); if (!whitelist || whitelist.trim() === '') { // No whitelist configured, allow all return next(); } // Get the direct connecting IP (the reverse proxy itself, not the end client) const proxyIp = (req.connection.remoteAddress || req.socket.remoteAddress || '').replace('::ffff:', ''); // Parse whitelist (can be comma-separated CIDRs) const allowedRanges = whitelist.split(',').map(s => s.trim()).filter(s => s); // Check if IP is in any of the allowed ranges for (const range of allowedRanges) { try { if (ipInCIDR(proxyIp, range)) { return next(); } } catch (e) { logger.warn(`Invalid CIDR range in whitelist: ${range}`); } } logger.warn(`Access denied for reverse proxy IP ${proxyIp} - not in whitelist`); return res.status(403).send('Access forbidden'); } async function startServer() { if (process.env.USING_HEROKU && process.env.PORT) { // default to heroku port if using heroku backendPort = process.env.PORT || backendPort; // set config to port await setPortItemFromENV(); } // Check if SSL certificates are configured const sslCertPath = config_api.getConfigItem('ytdl_ssl_cert_path'); const sslKeyPath = config_api.getConfigItem('ytdl_ssl_key_path'); let server; if (sslCertPath && sslKeyPath && fs.existsSync(sslCertPath) && fs.existsSync(sslKeyPath)) { // Start HTTPS server const httpsOptions = { cert: fs.readFileSync(sslCertPath), key: fs.readFileSync(sslKeyPath) }; server = https.createServer(httpsOptions, app); server.listen(backendPort, function() { logger.info(`YoutubeDL-Material ${CONSTS['CURRENT_VERSION']} started on HTTPS PORT ${backendPort}`); }); } else { // Start HTTP server if (sslCertPath || sslKeyPath) { logger.warn('SSL certificate or key path configured but files not found. Starting HTTP server instead.'); } server = http.createServer(app); server.listen(backendPort, function() { logger.info(`YoutubeDL-Material ${CONSTS['CURRENT_VERSION']} started on HTTP PORT ${backendPort}`); }); } } async function updateServer(tag) { // no tag provided means update to the latest version if (!tag) { const new_version_available = await isNewVersionAvailable(); if (!new_version_available) { logger.error('ERROR: Failed to update - no update is available.'); return false; } } return new Promise(async resolve => { // backup current dir updaterStatus = { updating: true, 'details': 'Backing up key server files...' } let backup_succeeded = await backupServerLite(); if (!backup_succeeded) { resolve(false); return false; } updaterStatus = { updating: true, 'details': 'Downloading requested release...' } // grab new package.json and public folder await downloadReleaseFiles(tag); updaterStatus = { updating: true, 'details': 'Installing new dependencies...' } // run npm install await installDependencies(); updaterStatus = { updating: true, 'details': 'Update complete! Restarting server...' } utils.restartServer(true); }, err => { logger.error(err); updaterStatus = { updating: false, error: true, 'details': 'Update failed. Check error logs for more info.' } }); } async function downloadReleaseFiles(tag) { tag = tag ? tag : await getLatestVersion(); const safeTag = getValidatedReleaseTag(tag); const releaseZipPath = getSafeReleaseZipPath(tag); if (!safeTag || !releaseZipPath) { logger.error(`Refusing to install release with invalid tag: ${tag}`); return false; } return new Promise(async resolve => { logger.info('Downloading new files...') // downloads the latest release zip file const zipDownloaded = await downloadReleaseZip(safeTag); if (!zipDownloaded) { resolve(false); return; } // deletes contents of public dir fs.removeSync(path.join(__dirname, 'public')); fs.mkdirSync(path.join(__dirname, 'public')); let replace_ignore_list = ['youtubedl-material/appdata/default.json', 'youtubedl-material/appdata/db.json', 'youtubedl-material/appdata/users.json', 'youtubedl-material/appdata/*'] logger.info(`Installing update ${safeTag}...`) // downloads new package.json and adds new public dir files from the downloaded zip fs.createReadStream(releaseZipPath).pipe(unzipper.Parse()) .on('entry', function (entry) { var fileName = entry.path; var is_dir = fileName.substring(fileName.length-1, fileName.length) === '/' if (!is_dir && fileName.includes('youtubedl-material/public/')) { // get public folder files const actualFileName = fileName.replace('youtubedl-material/public/', ''); if (actualFileName.length !== 0 && actualFileName.substring(actualFileName.length-1, actualFileName.length) !== '/') { const publicBasePath = path.join(__dirname, 'public'); const targetPublicPath = path.resolve(publicBasePath, actualFileName); const relativePublicPath = path.relative(publicBasePath, targetPublicPath); if (relativePublicPath.startsWith('..') || path.isAbsolute(relativePublicPath)) { logger.warn(`Skipping unsafe public file path during update extraction: ${actualFileName}`); entry.autodrain(); return; } fs.ensureDirSync(path.dirname(targetPublicPath)); entry.pipe(fs.createWriteStream(targetPublicPath)); } else { entry.autodrain(); } } else if (!is_dir && !replace_ignore_list.includes(fileName)) { // get package.json const actualFileName = fileName.replace('youtubedl-material/', ''); const repoBasePath = path.resolve(__dirname); const targetFilePath = path.resolve(repoBasePath, actualFileName); const relativeRepoPath = path.relative(repoBasePath, targetFilePath); if (relativeRepoPath.startsWith('..') || path.isAbsolute(relativeRepoPath)) { logger.warn(`Skipping unsafe file path during update extraction: ${actualFileName}`); entry.autodrain(); return; } logger.verbose('Downloading file ' + actualFileName); entry.pipe(fs.createWriteStream(targetFilePath)); } else { entry.autodrain(); } }) .on('close', function () { resolve(true); }); }); } async function downloadReleaseZip(tag) { return new Promise(async resolve => { const safeTag = getValidatedReleaseTag(tag); const resolvedOutputPath = getSafeReleaseZipPath(tag); if (!safeTag || !resolvedOutputPath) { logger.error(`Refusing to download release with invalid tag: ${tag}`); resolve(false); return; } // get name of zip file, which depends on the version const tag_without_v = safeTag.substring(1, safeTag.length); const zip_file_name = `youtubedl-material-${tag_without_v}.zip`; const latest_zip_link = `https://github.com/voc0der/YoutubeDL-Material/releases/download/${encodeURIComponent(safeTag)}/${encodeURIComponent(zip_file_name)}`; // download zip from release const res = await fetch(latest_zip_link); if (!res.ok) { logger.error(`Failed to download release zip for ${safeTag}: HTTP ${res.status}`); resolve(false); return; } await utils.writeFetchResponseToFile(res, fs.createWriteStream(resolvedOutputPath), 'update ' + safeTag); resolve(true); }); } async function installDependencies() { var child_process = require('child_process'); var exec = promisify(child_process.exec); await exec('npm install',{stdio:[0,1,2]}); return true; } async function backupServerLite() { await fs.ensureDir(path.join(__dirname, 'appdata', 'backups')); let output_path = path.join('appdata', 'backups', `backup-${Date.now()}.zip`); logger.info(`Backing up your non-video/audio files to ${output_path}. This may take up to a few seconds/minutes.`); let output = fs.createWriteStream(path.join(__dirname, output_path)); await new Promise(resolve => { var archive = archiver('zip', { gzip: true, zlib: { level: 9 } // Sets the compression level. }); archive.on('error', function(err) { logger.error(err); resolve(false); }); // pipe archive data to the output file archive.pipe(output); // ignore certain directories (ones with video or audio files) const files_to_ignore = [path.join(config_api.getConfigItem('ytdl_subscriptions_base_path'), '**'), path.join(config_api.getConfigItem('ytdl_audio_folder_path'), '**'), path.join(config_api.getConfigItem('ytdl_video_folder_path'), '**'), 'appdata/backups/backup-*.zip']; archive.glob('**/*', { ignore: files_to_ignore }); resolve(archive.finalize()); }); // wait a tiny bit for the zip to reload in fs await utils.wait(100); return true; } async function isNewVersionAvailable() { // gets tag of the latest version of youtubedl-material, compare to current version const latest_tag = await getLatestVersion(); const current_tag = CONSTS['CURRENT_VERSION']; if (latest_tag > current_tag) { return true; } else { return false; } } async function getLatestVersion() { const res = await fetch('https://api.github.com/repos/voc0der/youtubedl-material/releases/latest', {method: 'Get'}); const json = await res.json(); if (json['message']) { // means there's an error in getting latest version logger.error(`ERROR: Received the following message from GitHub's API:`); logger.error(json['message']); if (json['documentation_url']) logger.error(`Associated URL: ${json['documentation_url']}`) } return json['tag_name']; } async function killAllDownloads() { const lookupAsync = promisify(ps.lookup); let resultList = null; try { resultList = await lookupAsync({ command: 'youtube-dl' }); } catch (err) { // failed to get list of processes logger.error('Failed to get a list of running youtube-dl processes.'); logger.error(err); return { details: err, success: false }; } // processes that contain the string 'youtube-dl' in the name will be looped resultList.forEach(function( process ){ if (process) { ps.kill(process.pid, 'SIGKILL', function( err ) { if (err) { // failed to kill, process may have ended on its own logger.warn(`Failed to kill process with PID ${process.pid}`); logger.warn(err); } else { logger.verbose(`Process ${process.pid} has been killed!`); } }); } }); return { success: true }; } async function setPortItemFromENV() { config_api.setConfigItem('ytdl_port', backendPort.toString()); await utils.wait(100); return true; } function getOIDCMigrateTargetFromEnv() { return process.env.ytdl_oidc_migrate_videos || process.env.YTDL_OIDC_MIGRATE_VIDEOS || null; } function getScopedFilterByUser(user_uid) { if (!config_api.getConfigItem('ytdl_multi_user_mode')) return {}; return {user_uid: user_uid}; } async function migrateUnassignedVideosToConfiguredUser() { const migrate_target = getOIDCMigrateTargetFromEnv(); if (!migrate_target) return true; const normalized_target = String(migrate_target).trim(); const safe_uid = auth_api.sanitizeUserUID(normalized_target); if (!safe_uid) { throw new Error(`Invalid ytdl_oidc_migrate_videos value '${normalized_target}'. It must be a valid uid.`); } let target_user = await db_api.getRecord('users', {uid: safe_uid}); if (!target_user) { target_user = await db_api.getRecord('users', {name: normalized_target}); } if (!target_user) { throw new Error(`ytdl_oidc_migrate_videos requested migration to '${normalized_target}', but no such user exists.`); } const unassigned_filter = {user_uid: null}; const unassigned_count = await db_api.getRecords('files', unassigned_filter, true); if (!unassigned_count) { throw new Error('No unassigned videos/files exist for ytdl_oidc_migrate_videos. Remove this env variable to continue startup.'); } const migrated = await db_api.updateRecords('files', unassigned_filter, {user_uid: target_user.uid}); if (!migrated) { throw new Error(`Failed to migrate unassigned videos/files to user '${target_user.uid}'.`); } logger.info(`Migrated ${unassigned_count} unassigned videos/files to user '${target_user.uid}' from ytdl_oidc_migrate_videos.`); return true; } async function setAndLoadConfig() { try { await setConfigFromEnv(); await loadConfig(); } catch (err) { logger.error(`Startup failed: ${err.message}`); process.exit(1); } } async function setConfigFromEnv() { const config_items = getEnvConfigItems(); if (!config_items || config_items.length === 0) return true; const success = config_api.setConfigItems(config_items); if (success) { logger.info('Config items set using ENV variables.'); await utils.wait(100); return true; } else { logger.error('ERROR: Failed to set config items using ENV variables.'); return false; } } async function loadConfig() { loadConfigValues(); const oidc_enabled = oidc_api.isEnabled(); if (oidc_enabled && !config_api.getConfigItem('ytdl_multi_user_mode')) { logger.error('OIDC startup failed: multi-user mode must be enabled when OIDC is enabled.'); process.exit(1); } try { await oidc_api.initialize(); } catch (err) { logger.error(`OIDC startup failed: ${err.message}`); process.exit(1); } // connect to DB if (!config_api.getConfigItem('ytdl_use_local_db')) await db_api.connectToDB(); db_api.database_initialized = true; db_api.database_initialized_bs.next(true); // check migrations await checkMigrations(); await migrateUnassignedVideosToConfiguredUser(); // now this is done here due to youtube-dl's repo takedown await startYoutubeDL(); // get subscriptions if (allowSubscriptions) { // set downloading to false let subscriptions = await subscriptions_api.getAllSubscriptions(); subscriptions.forEach(async sub => subscriptions_api.writeSubscriptionMetadata(sub)); subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false, child_process: null}); // runs initially, then runs every ${subscriptionCheckInterval} seconds subscriptions_api.watchSubscriptionsInterval(); } // start the server here startServer(); return true; } function loadConfigValues() { url = !debugMode ? config_api.getConfigItem('ytdl_url') : 'http://localhost:4200'; backendPort = config_api.getConfigItem('ytdl_port'); useDefaultDownloadingAgent = config_api.getConfigItem('ytdl_use_default_downloading_agent'); customDownloadingAgent = config_api.getConfigItem('ytdl_custom_downloading_agent'); allowSubscriptions = config_api.getConfigItem('ytdl_allow_subscriptions'); if (!useDefaultDownloadingAgent && validDownloadingAgents.indexOf(customDownloadingAgent) !== -1 ) { logger.info(`Using non-default downloading agent \'${customDownloadingAgent}\'`) } else { customDownloadingAgent = null; } // empty url defaults to default URL if (!url || url === '') url = 'http://example.com' url_domain = new URL(url); let logger_level = config_api.getConfigItem('ytdl_logger_level'); utils.updateLoggerLevel(logger_level); configureExpressTrustProxy(); } function getOrigin() { if (process.env.CODESPACES) return `https://${process.env.CODESPACE_NAME}-4200.${process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}`; return url_domain.origin; } const VALID_RELEASE_TAG_PATTERN = /^v[0-9A-Za-z][0-9A-Za-z._-]*$/; const XML_ENTITY_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; function getValidatedReleaseTag(tag) { return (typeof tag === 'string' && VALID_RELEASE_TAG_PATTERN.test(tag)) ? tag : null; } function getSafeReleaseZipPath(tag) { const validTag = getValidatedReleaseTag(tag); if (!validTag) return null; const resolvedOutputPath = path.resolve(__dirname, `youtubedl-material-release-${validTag}.zip`); const relativeOutputPath = path.relative(__dirname, resolvedOutputPath); if (relativeOutputPath.startsWith('..') || path.isAbsolute(relativeOutputPath)) return null; return resolvedOutputPath; } function escapeXmlEntities(value) { if (value === undefined || value === null) return value; return String(value).replace(/[&<>"']/g, char => XML_ENTITY_MAP[char]); } // gets a list of config items that are stored as an environment variable function getEnvConfigItems() { let config_items = []; let config_item_keys = Object.keys(config_api.CONFIG_ITEMS); for (let i = 0; i < config_item_keys.length; i++) { let key = config_item_keys[i]; if (process['env'][key] || process['env'][key.toUpperCase()]) { const config_item = generateEnvVarConfigItem(key); config_items.push(config_item); } } return config_items; } // gets value of a config item and stores it in an object function generateEnvVarConfigItem(key) { return {key: key, value: process['env'][key] || process['env'][key.toUpperCase()]}; } // youtube-dl functions async function startYoutubeDL() { // auto update youtube-dl await youtubedl_api.checkForYoutubeDLUpdate(); } app.use(function(req, res, next) { res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization"); res.header("Access-Control-Allow-Origin", getOrigin()); if (req.method === 'OPTIONS') { res.sendStatus(200); } else { next(); } }); app.use(function(req, res, next) { if (!req.path.includes('/api/')) { next(); } else if (req.path.includes('/api/auth/oidc/login') || req.path.includes('/api/auth/oidc/callback') || req.path.includes('/api/auth/oidc/status')) { next(); } else if (req.query.apiKey === admin_token) { next(); } else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) { next(); } else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/') || req.path.includes('/api/rss') || req.path.includes('/api/telegramRequest')) { next(); } else { logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key present: ${!!req.query.apiKey}`); req.socket.end(); } }); app.use(compression()); const rateLimitValidateOptions = { xForwardedForHeader: false }; const testCookiesRateLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, validate: rateLimitValidateOptions, message: { success: false, error: 'Too many cookie test requests. Please wait a minute and try again.' } }); const apiRateLimiter = rateLimit({ windowMs: 60 * 1000, max: 300, standardHeaders: true, legacyHeaders: false, validate: rateLimitValidateOptions, // Keep public media/feed endpoints usable while protecting stateful/file-system routes. skip: (req) => req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/') || req.path.includes('/api/rss') || req.path.includes('/api/telegramRequest') }); const authRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 25, standardHeaders: true, legacyHeaders: false, validate: rateLimitValidateOptions, message: { success: false, error: 'Too many authentication requests. Please wait and try again.' } }); app.use('/api', apiRateLimiter); app.use('/api/auth', authRateLimiter); function isPublicAuthPath(req_path) { return req_path.includes('/api/auth/register') || req_path.includes('/api/auth/oidc/login') || req_path.includes('/api/auth/oidc/callback') || req_path.includes('/api/auth/oidc/status'); } const optionalJwt = async function (req, res, next) { const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); if (multiUserMode && ((req.body && req.body.uuid) || (req.query && req.query.uuid)) && (req.path.includes('/api/getFile') || req.path.includes('/api/stream') || req.path.includes('/api/getPlaylist') || req.path.includes('/api/downloadFileFromServer'))) { // check if shared video const using_body = req.body && req.body.uuid; const uuid = using_body ? req.body.uuid : req.query.uuid; const uid = using_body ? req.body.uid : req.query.uid; const playlist_id = using_body ? req.body.playlist_id : req.query.playlist_id; const file = !playlist_id ? await auth_api.getUserVideo(uuid, uid, true) : await files_api.getPlaylist(playlist_id, uuid, true); if (file) { req.can_watch = true; return next(); } else { res.sendStatus(401); return; } } else if (multiUserMode && !isPublicAuthPath(req.path)) { if (!req.query.jwt) { res.sendStatus(401); return; } return auth_api.passport.authenticate('jwt', { session: false })(req, res, next); } return next(); }; app.get('/api/config', function(req, res) { let config_file = config_api.getConfigFile(); res.send({ config_file: config_file, success: !!config_file }); }); app.post('/api/setConfig', optionalJwt, function(req, res) { let new_config_file = req.body.new_config_file; if (new_config_file && new_config_file['YoutubeDLMaterial']) { let success = config_api.setConfigFile(new_config_file); loadConfigValues(); // reloads config values that exist as variables res.send({ success: success }); } else { logger.error('Tried to save invalid config file!') res.sendStatus(400); } }); app.get('/api/versionInfo', (req, res) => { res.send({version_info: version_info}); }); app.post('/api/restartServer', optionalJwt, (req, res) => { // delayed by a little bit so that the client gets a response setTimeout(() => {utils.restartServer()}, 100); res.send({success: true}); }); app.get('/api/getDBInfo', optionalJwt, async (req, res) => { const db_info = await db_api.getDBStats(); res.send(db_info); }); app.post('/api/transferDB', optionalJwt, async (req, res) => { const local_to_remote = req.body.local_to_remote; let success = null; let error = ''; if (local_to_remote === config_api.getConfigItem('ytdl_use_local_db')) { success = await db_api.transferDB(local_to_remote); if (!success) error = 'Unknown error'; else config_api.setConfigItem('ytdl_use_local_db', !local_to_remote); } else { success = false; error = `Failed to transfer DB as it cannot transition into its current status: ${local_to_remote ? 'MongoDB' : 'Local DB'}`; logger.error(error); } res.send({success: success, error: error}); }); app.post('/api/testConnectionString', optionalJwt, async (req, res) => { const connection_string = req.body.connection_string; let success = null; let error = ''; success = await db_api.connectToDB(0, true, connection_string); if (!success) error = 'Connection string failed.'; res.send({success: success, error: error}); }); app.post('/api/downloadFile', optionalJwt, async function(req, res) { req.setTimeout(0); // remove timeout in case of long videos const url = req.body.url; const type = req.body.type ? req.body.type : 'video'; const user_uid = req.isAuthenticated() ? req.user.uid : null; const options = { customArgs: req.body.customArgs, additionalArgs: req.body.additionalArgs, customOutput: req.body.customOutput, selectedHeight: req.body.selectedHeight, maxHeight: req.body.maxHeight, customQualityConfiguration: req.body.customQualityConfiguration, youtubeUsername: req.body.youtubeUsername, youtubePassword: req.body.youtubePassword, ui_uid: req.body.ui_uid, cropFileSettings: req.body.cropFileSettings, ignoreArchive: req.body.ignoreArchive }; const download = await downloader_api.createDownload(url, type, options, user_uid); if (download) { res.send({download: download}); } else { res.sendStatus(500); } }); app.post('/api/killAllDownloads', optionalJwt, async function(req, res) { const result_obj = await killAllDownloads(); res.send(result_obj); }); app.post('/api/generateArgs', optionalJwt, async function(req, res) { const url = req.body.url; const type = req.body.type; const user_uid = req.isAuthenticated() ? req.user.uid : null; const options = { customArgs: req.body.customArgs, additionalArgs: req.body.additionalArgs, customOutput: req.body.customOutput, selectedHeight: req.body.selectedHeight, maxHeight: req.body.maxHeight, customQualityConfiguration: req.body.customQualityConfiguration, youtubeUsername: req.body.youtubeUsername, youtubePassword: req.body.youtubePassword, ui_uid: req.body.ui_uid, cropFileSettings: req.body.cropFileSettings }; const args = await downloader_api.generateArgs(url, type, options, user_uid, true); res.send({args: args}); }); // gets all download mp3s app.get('/api/getMp3s', optionalJwt, async function(req, res) { // TODO: simplify let mp3s = await db_api.getRecords('files', {isAudio: true}); let playlists = await db_api.getRecords('playlists'); const is_authenticated = req.isAuthenticated(); if (is_authenticated) { // get user audio files/playlists auth_api.passport.authenticate('jwt') mp3s = await db_api.getRecords('files', {user_uid: req.user.uid, isAudio: true}); playlists = await db_api.getRecords('playlists', {user_uid: req.user.uid}); // TODO: remove? } mp3s = JSON.parse(JSON.stringify(mp3s)); res.send({ mp3s: mp3s, playlists: playlists }); }); // gets all download mp4s app.get('/api/getMp4s', optionalJwt, async function(req, res) { let mp4s = await db_api.getRecords('files', {isAudio: false}); let playlists = await db_api.getRecords('playlists'); const is_authenticated = req.isAuthenticated(); if (is_authenticated) { // get user videos/playlists auth_api.passport.authenticate('jwt') mp4s = await db_api.getRecords('files', {user_uid: req.user.uid, isAudio: false}); playlists = await db_api.getRecords('playlists', {user_uid: req.user.uid}); // TODO: remove? } mp4s = JSON.parse(JSON.stringify(mp4s)); res.send({ mp4s: mp4s, playlists: playlists }); }); app.post('/api/getFile', optionalJwt, async function (req, res) { const uid = req.body.uid; const uuid = req.body.uuid; let file = null; if (req.isAuthenticated()) { file = await files_api.getVideo(uid, req.user.uid); } else if (uuid) { file = await auth_api.getUserVideo(uuid, uid, true); } else { file = await files_api.getVideo(uid); } // check if chat exists for twitch videos if (file && file['url'].includes('twitch.tv')) file['chat_exists'] = fs.existsSync(file['path'].substring(0, file['path'].length - 4) + '.twitch_chat.json'); if (file) { res.send({ success: true, file: file }); } else { res.send({ success: false }); } }); app.post('/api/getAllFiles', optionalJwt, async function (req, res) { // these are returned const sort = req.body.sort; const range = req.body.range; const text_search = req.body.text_search; const file_type_filter = req.body.file_type_filter; const favorite_filter = req.body.favorite_filter; const sub_id = req.body.sub_id; const uuid = req.isAuthenticated() ? req.user.uid : null; const {files, file_count} = await files_api.getAllFiles(sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid); res.send({ files: files, file_count: file_count, }); }); app.post('/api/updateFile', optionalJwt, async function (req, res) { const uid = req.body.uid; const change_obj = req.body.change_obj; const file = await db_api.updateRecord('files', {uid: uid}, change_obj); if (!file) { res.send({ success: false, error: 'File could not be found' }); } else { res.send({ success: true }); } }); app.post('/api/checkConcurrentStream', async (req, res) => { const uid = req.body.uid; const DEAD_SERVER_THRESHOLD = 10; if (concurrentStreams[uid] && Date.now()/1000 - concurrentStreams[uid]['unix_timestamp'] > DEAD_SERVER_THRESHOLD) { logger.verbose( `Killing dead stream on ${uid}`); delete concurrentStreams[uid]; } res.send({stream: concurrentStreams[uid]}) }); app.post('/api/updateConcurrentStream', optionalJwt, async (req, res) => { const uid = req.body.uid; const playback_timestamp = req.body.playback_timestamp; const unix_timestamp = req.body.unix_timestamp; const playing = req.body.playing; concurrentStreams[uid] = { playback_timestamp: playback_timestamp, unix_timestamp: unix_timestamp, playing: playing } res.send({stream: concurrentStreams[uid]}) }); app.post('/api/getFullTwitchChat', optionalJwt, async (req, res) => { var id = req.body.id; var type = req.body.type; var uuid = req.body.uuid; var sub = req.body.sub; var user_uid = null; if (req.isAuthenticated()) { user_uid = req.user.uid; uuid = req.user.uid; } const chat_file = await twitch_api.getTwitchChatByFileID(id, type, user_uid, uuid, sub); res.send({ chat: chat_file }); }); app.post('/api/downloadTwitchChatByVODID', optionalJwt, async (req, res) => { var id = req.body.id; var type = req.body.type; var vodId = req.body.vodId; var uuid = req.body.uuid; var sub = req.body.sub; var user_uid = null; if (req.isAuthenticated()) { user_uid = req.user.uid; uuid = req.user.uid; } // check if file already exists. if so, send that instead const file_exists_check = await twitch_api.getTwitchChatByFileID(id, type, user_uid, uuid, sub); if (file_exists_check) { res.send({chat: file_exists_check}); return; } const full_chat = await twitch_api.downloadTwitchChatByVODID(vodId, id, type, user_uid, sub); res.send({ chat: full_chat }); }); // video sharing app.post('/api/enableSharing', optionalJwt, async (req, res) => { var uid = req.body.uid; var is_playlist = req.body.is_playlist; let success = false; // multi-user mode if (req.isAuthenticated()) { // if multi user mode, use this method instead success = auth_api.changeSharingMode(req.user.uid, uid, is_playlist, true); res.send({success: success}); return; } // single-user mode try { success = true; if (!is_playlist) { await db_api.updateRecord('files', {uid: uid}, {sharingEnabled: true}) } else if (is_playlist) { await db_api.updateRecord(`playlists`, {id: uid}, {sharingEnabled: true}); } else if (false) { // TODO: Implement. } else { // error success = false; } } catch(err) { logger.error(err); success = false; } res.send({ success: success }); }); app.post('/api/disableSharing', optionalJwt, async function(req, res) { var type = req.body.type; var uid = req.body.uid; var is_playlist = req.body.is_playlist; let success = null; try { success = true; if (!is_playlist && type !== 'subscription') { await db_api.updateRecord('files', {uid: uid}, {sharingEnabled: false}) } else if (is_playlist) { await db_api.updateRecord(`playlists`, {id: uid}, {sharingEnabled: false}); } else { // error success = false; } } catch(err) { success = false; } res.send({ success: success }); }); app.post('/api/incrementViewCount', async (req, res) => { let file_uid = req.body.file_uid; let sub_id = req.body.sub_id; let uuid = req.body.uuid; if (req.isAuthenticated()) uuid = req.user.uid; const file_obj = await files_api.getVideo(file_uid, uuid, sub_id); const current_view_count = file_obj && file_obj['local_view_count'] ? file_obj['local_view_count'] : 0; const new_view_count = current_view_count + 1; await db_api.setVideoProperty(file_uid, {local_view_count: new_view_count}, uuid, sub_id); res.send({ success: true }); }); // categories app.post('/api/getAllCategories', optionalJwt, async (req, res) => { const categories = await db_api.getRecords('categories'); res.send({categories: categories}); }); app.post('/api/createCategory', optionalJwt, async (req, res) => { const name = req.body.name; const new_category = { name: name, uid: uuid(), rules: [], custom_output: '' }; await db_api.insertRecordIntoTable('categories', new_category); res.send({ new_category: new_category, success: !!new_category }); }); app.post('/api/deleteCategory', optionalJwt, async (req, res) => { const category_uid = req.body.category_uid; await db_api.removeRecord('categories', {uid: category_uid}); res.send({ success: true }); }); app.post('/api/updateCategory', optionalJwt, async (req, res) => { const category = req.body.category; await db_api.updateRecord('categories', {uid: category.uid}, category) res.send({success: true}); }); app.post('/api/updateCategories', optionalJwt, async (req, res) => { const categories = req.body.categories; await db_api.removeAllRecords('categories'); await db_api.insertRecordsIntoTable('categories', categories); res.send({success: true}); }); // subscriptions app.post('/api/subscribe', optionalJwt, async (req, res) => { let name = req.body.name; let url = req.body.url; let maxQuality = req.body.maxQuality; let timerange = req.body.timerange; let audioOnly = req.body.audioOnly; let customArgs = req.body.customArgs; let customOutput = req.body.customFileOutput; let user_uid = req.isAuthenticated() ? req.user.uid : null; const new_sub = { name: name, url: url, maxQuality: maxQuality, id: uuid(), user_uid: user_uid, type: audioOnly ? 'audio' : 'video' }; // adds timerange if it exists, otherwise all videos will be downloaded if (timerange) { new_sub.timerange = timerange; } if (customArgs && customArgs !== '') { new_sub.custom_args = customArgs; } if (customOutput && customOutput !== '') { new_sub.custom_output = customOutput; } const result_obj = await subscriptions_api.subscribe(new_sub, user_uid); if (result_obj.success) { res.send({ new_sub: new_sub }); } else { res.send({ new_sub: null, error: result_obj.error }) } }); app.post('/api/unsubscribe', optionalJwt, async (req, res) => { let deleteMode = req.body.deleteMode let sub_id = req.body.sub_id; let user_uid = req.isAuthenticated() ? req.user.uid : null; let result_obj = await subscriptions_api.unsubscribe(sub_id, deleteMode, user_uid); if (result_obj.success) { res.send({ success: result_obj.success }); } else { res.send({ success: false, error: result_obj.error }); } }); app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => { let deleteForever = req.body.deleteForever; let file_uid = req.body.file_uid; const user_uid = req.isAuthenticated() ? req.user.uid : null; let success = await files_api.deleteFile(file_uid, deleteForever, user_uid); if (success) { res.send({ success: success }); } else { res.sendStatus(500); } }); app.post('/api/getSubscription', optionalJwt, async (req, res) => { let subID = req.body.id; let subName = req.body.name; // if included, subID is optional let user_uid = req.isAuthenticated() ? req.user.uid : null; // get sub from db let subscription = null; if (subID) { subscription = await subscriptions_api.getSubscription(subID, user_uid) } else if (subName) { subscription = await subscriptions_api.getSubscriptionByName(subName, user_uid) } if (!subscription) { // failed to get subscription from db, send 400 error res.sendStatus(400); return; } subscription = JSON.parse(JSON.stringify(subscription)); // get sub videos if (subscription.name) { const sub_files_filter = {sub_id: subscription.id, ...getScopedFilterByUser(user_uid)}; var parsed_files = await db_api.getRecords('files', sub_files_filter); // subscription.videos; subscription['videos'] = parsed_files; // loop through files for extra processing for (let i = 0; i < parsed_files.length; i++) { const file = parsed_files[i]; // check if chat exists for twitch videos if (file && file['url'].includes('twitch.tv')) file['chat_exists'] = fs.existsSync(file['path'].substring(0, file['path'].length - 4) + '.twitch_chat.json'); } res.send({ subscription: subscription, files: parsed_files }); } else { res.sendStatus(500); } }); app.post('/api/downloadVideosForSubscription', optionalJwt, async (req, res) => { const subID = req.body.subID; const user_uid = req.isAuthenticated() ? req.user.uid : null; const sub = await subscriptions_api.getSubscription(subID, user_uid); if (!sub) { res.send({success: false}); return; } subscriptions_api.getVideosForSub(sub.id); res.send({ success: true }); }); app.post('/api/updateSubscription', optionalJwt, async (req, res) => { const updated_sub = req.body.subscription; const user_uid = req.isAuthenticated() ? req.user.uid : null; const success = await subscriptions_api.updateSubscription(updated_sub, user_uid); res.send({ success: success }); }); app.post('/api/checkSubscription', optionalJwt, async (req, res) => { let sub_id = req.body.sub_id; let user_uid = req.isAuthenticated() ? req.user.uid : null; const success = await subscriptions_api.getVideosForSub(sub_id, user_uid); res.send({ success: success }); }); app.post('/api/cancelCheckSubscription', optionalJwt, async (req, res) => { let sub_id = req.body.sub_id; let user_uid = req.isAuthenticated() ? req.user.uid : null; const success = await subscriptions_api.cancelCheckSubscription(sub_id, user_uid); res.send({ success: success }); }); app.post('/api/cancelSubscriptionCheck', optionalJwt, async (req, res) => { let sub_id = req.body.sub_id; let user_uid = req.isAuthenticated() ? req.user.uid : null; const success = await subscriptions_api.getVideosForSub(sub_id, user_uid); res.send({ success: success }); }); app.post('/api/getSubscriptions', optionalJwt, async (req, res) => { let user_uid = req.isAuthenticated() ? req.user.uid : null; // get subs from api let subscriptions = await subscriptions_api.getSubscriptions(user_uid); res.send({ subscriptions: subscriptions }); }); app.post('/api/createPlaylist', optionalJwt, async (req, res) => { let playlistName = req.body.playlistName; let uids = req.body.uids; const new_playlist = await files_api.createPlaylist(playlistName, uids, req.isAuthenticated() ? req.user.uid : null); res.send({ new_playlist: new_playlist, success: !!new_playlist // always going to be true }) }); app.post('/api/getPlaylist', optionalJwt, async (req, res) => { let playlist_id = req.body.playlist_id; let uuid = req.body.uuid ? req.body.uuid : (req.user && req.user.uid ? req.user.uid : null); let include_file_metadata = req.body.include_file_metadata; if (req.user && req.user.uid) uuid = req.user.uid; const playlist = await files_api.getPlaylist(playlist_id, uuid); const file_objs = []; if (playlist && include_file_metadata) { for (let i = 0; i < playlist['uids'].length; i++) { const uid = playlist['uids'][i]; const file_obj = await files_api.getVideo(uid, uuid); if (file_obj) file_objs.push(file_obj); // TODO: remove file from playlist if could not be found } } res.send({ playlist: playlist, file_objs: file_objs, success: !!playlist }); }); app.post('/api/getPlaylists', optionalJwt, async (req, res) => { const uuid = req.isAuthenticated() ? req.user.uid : null; const include_categories = req.body.include_categories; const filter_obj = getScopedFilterByUser(uuid); let playlists = await db_api.getRecords('playlists', filter_obj); if (include_categories) { const categories = await categories_api.getCategoriesAsPlaylists(); if (categories) { playlists = playlists.concat(categories); } } res.send({ playlists: playlists }); }); app.post('/api/addFileToPlaylist', optionalJwt, async (req, res) => { let playlist_id = req.body.playlist_id; let file_uid = req.body.file_uid; const user_uid = req.isAuthenticated() ? req.user.uid : null; const playlist = await files_api.getPlaylist(playlist_id, user_uid); if (!playlist) { res.send({ success: false }); return; } const file_obj = await files_api.getVideo(file_uid, user_uid); if (!file_obj) { res.send({ success: false }); return; } playlist.uids.push(file_uid); let success = await files_api.updatePlaylist(playlist, user_uid); res.send({ success: success }); }); app.post('/api/updatePlaylist', optionalJwt, async (req, res) => { let playlist = req.body.playlist; let success = await files_api.updatePlaylist(playlist, req.user && req.user.uid); res.send({ success: success }); }); app.post('/api/deletePlaylist', optionalJwt, async (req, res) => { let playlistID = req.body.playlist_id; const user_uid = req.isAuthenticated() ? req.user.uid : null; let success = null; try { // removes playlist from playlists const filter_obj = {id: playlistID, ...getScopedFilterByUser(user_uid)}; await db_api.removeRecord('playlists', filter_obj) success = true; } catch(e) { success = false; } res.send({ success: success }) }); // deletes non-subscription files app.post('/api/deleteFile', optionalJwt, async (req, res) => { const uid = req.body.uid; const blacklistMode = req.body.blacklistMode; const user_uid = req.isAuthenticated() ? req.user.uid : null; let wasDeleted = false; wasDeleted = await files_api.deleteFile(uid, blacklistMode, user_uid); res.send(wasDeleted); }); app.post('/api/deleteAllFiles', optionalJwt, async (req, res) => { const blacklistMode = false; const uuid = req.isAuthenticated() ? req.user.uid : null; let files = null; let text_search = req.body.text_search; let file_type_filter = req.body.file_type_filter; const filter_obj = getScopedFilterByUser(uuid); const regex = true; if (text_search) { if (regex) { filter_obj['title'] = {$regex: `.*${text_search}.*`, $options: 'i'}; } else { filter_obj['$text'] = { $search: utils.createEdgeNGrams(text_search) }; } } if (file_type_filter === 'audio_only') filter_obj['isAudio'] = true; else if (file_type_filter === 'video_only') filter_obj['isAudio'] = false; files = await db_api.getRecords('files', filter_obj); let file_count = await db_api.getRecords('files', filter_obj, true); let delete_count = 0; for (let i = 0; i < files.length; i++) { let wasDeleted = false; wasDeleted = await files_api.deleteFile(files[i].uid, blacklistMode, uuid); if (wasDeleted) { delete_count++; } } res.send({ file_count: file_count, delete_count: delete_count }); }); app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => { let uid = req.body.uid; let uuid = req.body.uuid; let playlist_id = req.body.playlist_id; let sub_id = req.body.sub_id; let file_path_to_download = null; if (req.user && req.user.uid) uuid = req.user.uid; let zip_file_generated = false; if (playlist_id) { zip_file_generated = true; const playlist_files_to_download = []; const playlist = await files_api.getPlaylist(playlist_id, uuid); if (!playlist) { res.sendStatus(404); return; } for (let i = 0; i < playlist['uids'].length; i++) { const playlist_file_uid = playlist['uids'][i]; const file_obj = await files_api.getVideo(playlist_file_uid, uuid); if (file_obj) playlist_files_to_download.push(file_obj); } // generate zip file_path_to_download = await utils.createContainerZipFile(playlist['name'], playlist_files_to_download); } else if (sub_id && !uid) { zip_file_generated = true; const sub = await subscriptions_api.getSubscription(sub_id, req.isAuthenticated() ? req.user.uid : null); if (!sub) { res.sendStatus(404); return; } const sub_files_to_download = await db_api.getRecords('files', {sub_id: sub_id, ...getScopedFilterByUser(req.isAuthenticated() ? req.user.uid : null)}); // generate zip file_path_to_download = await utils.createContainerZipFile(sub['name'], sub_files_to_download); } else { const file_obj = await files_api.getVideo(uid, uuid, sub_id) if (!file_obj) { res.sendStatus(404); return; } file_path_to_download = file_obj.path; } if (!path.isAbsolute(file_path_to_download)) file_path_to_download = path.join(__dirname, file_path_to_download); res.sendFile(file_path_to_download, function (err) { if (err) { logger.error(err); } else if (zip_file_generated) { try { // delete generated zip file fs.unlinkSync(file_path_to_download); } catch(e) { logger.error(`Failed to remove file after sending to client: ${file_path_to_download}`); } } }); }); app.post('/api/getArchives', optionalJwt, async (req, res) => { const uuid = req.isAuthenticated() ? req.user.uid : null; const sub_id = req.body.sub_id; const filter_obj = {sub_id: sub_id, ...getScopedFilterByUser(uuid)}; const type = req.body.type; // we do this for file types because if type is null, that means get files of all types if (type) filter_obj['type'] = type; const archives = await db_api.getRecords('archives', filter_obj); res.send({ archives: archives }); }); app.post('/api/downloadArchive', optionalJwt, async (req, res) => { const uuid = req.isAuthenticated() ? req.user.uid : null; const sub_id = req.body.sub_id; const type = req.body.type; const archive_text = await archive_api.generateArchive(type, uuid, sub_id); if (archive_text !== null && archive_text !== undefined) { res.setHeader('Content-type', "application/octet-stream"); res.setHeader('Content-disposition', 'attachment; filename=archive.txt'); res.send(archive_text); } else { res.sendStatus(400); } }); app.post('/api/importArchive', optionalJwt, async (req, res) => { const uuid = req.isAuthenticated() ? req.user.uid : null; const archive = req.body.archive; const sub_id = req.body.sub_id; const type = req.body.type; const archive_text = Buffer.from(archive.split(',')[1], 'base64').toString(); const imported_count = await archive_api.importArchiveFile(archive_text, type, uuid, sub_id); res.send({ success: !!imported_count, imported_count: imported_count }); }); app.post('/api/deleteArchiveItems', optionalJwt, async (req, res) => { const uuid = req.isAuthenticated() ? req.user.uid : null; const archives = req.body.archives; let success = true; for (const archive of archives) { success &= await archive_api.removeFromArchive(archive['extractor'], archive['id'], archive['type'], uuid, archive['sub_id']); } res.send({ success: success }); }); var upload_multer = multer({ dest: __dirname + '/appdata/' }); app.post('/api/uploadCookies', upload_multer.single('cookies'), async (req, res) => { if (!req.file || !req.file.path) { res.sendStatus(400); return; } const new_path = path.join(__dirname, 'appdata', 'cookies.txt'); const uploadBasePath = path.join(__dirname, 'appdata'); const resolvedUploadedPath = path.resolve(req.file.path); const relativeUploadedPath = path.relative(uploadBasePath, resolvedUploadedPath); if (relativeUploadedPath.startsWith('..') || path.isAbsolute(relativeUploadedPath)) { logger.error(`Refusing to move uploaded cookies file outside appdata: ${req.file.path}`); res.sendStatus(400); return; } if (await fs.pathExists(resolvedUploadedPath)) { await fs.rename(resolvedUploadedPath, new_path); } else { res.sendStatus(500); return; } if (await fs.pathExists(new_path)) { res.send({success: true}); } else { res.sendStatus(500); } }); function getCookiesFileSummary(cookies_text) { const lines = cookies_text.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0); const cookie_lines = lines.filter(line => !line.startsWith('#') || line.startsWith('#HttpOnly_')); let invalid_entries = 0; for (const line of cookie_lines) { // Netscape cookie format should contain at least 7 tab-separated values. if (line.split('\t').length < 7) invalid_entries++; } return { total_lines: lines.length, cookie_entries: cookie_lines.length, invalid_entries: invalid_entries }; } function normalizeCookieTestError(err) { if (!err) return 'Unknown error.'; let message = null; if (typeof err === 'string') { message = err; } else if (err.stderr) { message = err.stderr.toString(); } else if (err.message) { message = err.message.toString(); } else { message = JSON.stringify(err); } if (!message) return 'Unknown error.'; const max_error_length = 1200; return message.length > max_error_length ? message.substring(0, max_error_length) + '...' : message; } app.post('/api/testCookies', testCookiesRateLimiter, optionalJwt, async (req, res) => { const logs = []; const use_cookies_enabled = config_api.getConfigItem('ytdl_use_cookies'); const downloader = config_api.getConfigItem('ytdl_default_downloader'); const test_url = req.body && req.body.url ? req.body.url.trim() : ''; const cookie_path = path.join(__dirname, 'appdata', 'cookies.txt'); const relative_cookie_path = path.join('appdata', 'cookies.txt'); logs.push('Starting cookie test.'); logs.push(`Downloader: ${downloader}`); logs.push(`Use Cookies setting is ${use_cookies_enabled ? 'enabled' : 'disabled'}.`); if (!test_url) { logs.push('No URL was provided for cookie testing.'); res.status(400).send({ success: false, error: 'Missing URL to test.', logs: logs, use_cookies_enabled: use_cookies_enabled, cookie_file_found: false }); return; } let parsed_test_url = null; try { parsed_test_url = new URL(test_url); } catch (err) { parsed_test_url = null; } if (!parsed_test_url || (parsed_test_url.protocol !== 'http:' && parsed_test_url.protocol !== 'https:')) { logs.push(`Invalid test URL provided: ${test_url}`); res.status(400).send({ success: false, error: 'Invalid URL. Only http/https URLs are allowed.', logs: logs, use_cookies_enabled: use_cookies_enabled, cookie_file_found: false }); return; } if (!(await fs.pathExists(cookie_path))) { logs.push(`Cookie file was not found at ${cookie_path}.`); res.send({ success: false, error: 'Cookies file not found.', logs: logs, use_cookies_enabled: use_cookies_enabled, cookie_file_found: false }); return; } const cookie_stats = await fs.stat(cookie_path); logs.push(`Cookie file found (${cookie_stats.size} bytes).`); if (cookie_stats.size === 0) { logs.push('Cookie file is empty.'); res.send({ success: false, error: 'Cookies file is empty.', logs: logs, use_cookies_enabled: use_cookies_enabled, cookie_file_found: true, cookie_file_size: cookie_stats.size }); return; } const cookies_text = await fs.readFile(cookie_path, 'utf8'); const cookie_summary = getCookiesFileSummary(cookies_text); logs.push(`Detected ${cookie_summary.cookie_entries} cookie entries from ${cookie_summary.total_lines} non-empty lines.`); if (cookie_summary.invalid_entries > 0) { logs.push(`Detected ${cookie_summary.invalid_entries} entries that may not be valid Netscape cookie rows.`); } const args = [ '--skip-download', '--no-warnings', '--no-playlist', '--dump-single-json', '--cookies', relative_cookie_path ]; logs.push(`Testing URL: ${test_url}`); logs.push(`Executing test command with cookies at ${relative_cookie_path}.`); let run_response = null; try { run_response = await youtubedl_api.runYoutubeDL(test_url, args); } catch (err) { const error_message = normalizeCookieTestError(err); logs.push(`Failed to start downloader process. ${error_message}`); res.status(500).send({ success: false, error: error_message, logs: logs, use_cookies_enabled: use_cookies_enabled, cookie_file_found: true, cookie_summary: cookie_summary }); return; } if (!run_response || !run_response.callback) { logs.push('Downloader process did not initialize correctly.'); res.status(500).send({ success: false, error: 'Failed to initialize downloader process.', logs: logs, use_cookies_enabled: use_cookies_enabled, cookie_file_found: true, cookie_summary: cookie_summary }); return; } const {parsed_output, err} = await run_response.callback; if (parsed_output && parsed_output.length > 0) { const info_obj = parsed_output[0]; const title = info_obj && info_obj.title ? info_obj.title : null; const extractor = info_obj && info_obj.extractor ? info_obj.extractor : null; if (title) logs.push(`Metadata fetch succeeded: "${title}".`); else logs.push('Metadata fetch succeeded.'); if (extractor) logs.push(`Extractor used: ${extractor}.`); res.send({ success: true, logs: logs, use_cookies_enabled: use_cookies_enabled, cookie_file_found: true, cookie_file_size: cookie_stats.size, cookie_summary: cookie_summary, result: { title: title, extractor: extractor } }); return; } const error_message = normalizeCookieTestError(err); logs.push('Metadata fetch failed while using cookies.'); logs.push(error_message); res.send({ success: false, error: error_message, logs: logs, use_cookies_enabled: use_cookies_enabled, cookie_file_found: true, cookie_file_size: cookie_stats.size, cookie_summary: cookie_summary }); }); // Updater API calls app.get('/api/updaterStatus', optionalJwt, async (req, res) => { let status = updaterStatus; if (status) { res.send(updaterStatus); } else { res.sendStatus(404); } }); app.post('/api/updateServer', optionalJwt, async (req, res) => { let tag = req.body.tag; updateServer(tag); res.send({ success: true }); }); // API Key API calls app.post('/api/generateNewAPIKey', optionalJwt, function (req, res) { const new_api_key = uuid(); config_api.setConfigItem('ytdl_api_key', new_api_key); res.send({new_api_key: new_api_key}); }); // Streaming API calls app.get('/api/stream', optionalJwt, async (req, res) => { const type = req.query.type; const uuid = req.user ? req.user.uid : (req.query.uuid ? req.query.uuid : null); const sub_id = req.query.sub_id; const mimetype = type === 'audio' ? 'audio/mp3' : 'video/mp4'; var head; let uid = decodeURIComponent(req.query.uid); let file_path = null; let file_obj = null; const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); if (!multiUserMode || req.isAuthenticated() || req.can_watch) { file_obj = await files_api.getVideo(uid, uuid, sub_id); if (file_obj) file_path = file_obj['path']; else file_path = null; } if (!fs.existsSync(file_path)) { logger.error(`File ${file_path} could not be found! UID: ${uid}, ID: ${file_obj && file_obj.id}`); return; } const stat = fs.statSync(file_path); const fileSize = stat.size; const range = req.headers.range; if (range) { const parts = range.replace(/bytes=/, "").split("-") const start = parseInt(parts[0], 10) const end = parts[1] ? parseInt(parts[1], 10) : fileSize-1 const chunksize = (end-start)+1 const file = fs.createReadStream(file_path, {start, end}) if (config_api.descriptors[uid]) config_api.descriptors[uid].push(file); else config_api.descriptors[uid] = [file]; file.on('close', function() { let index = config_api.descriptors[uid].indexOf(file); config_api.descriptors[uid].splice(index, 1); logger.debug('Successfully closed stream and removed file reference.'); }); head = { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunksize, 'Content-Type': mimetype, } res.writeHead(206, head); file.pipe(res); } else { head = { 'Content-Length': fileSize, 'Content-Type': mimetype, } res.writeHead(200, head) fs.createReadStream(file_path).pipe(res) } }); app.get('/api/thumbnail/:path', optionalJwt, async (req, res) => { // Express route params are already decoded. const requestedPath = typeof req.params.path === 'string' ? req.params.path : ''; const resolvedRequestedPath = path.isAbsolute(requestedPath) ? path.resolve(requestedPath) : path.resolve(__dirname, requestedPath); const thumbnailExt = path.extname(resolvedRequestedPath).toLowerCase(); const allowedThumbnailExts = new Set(['.jpg', '.jpeg', '.png', '.webp']); if (!allowedThumbnailExts.has(thumbnailExt)) { res.sendStatus(400); return; } const configuredRoots = [ __dirname, config_api.getConfigItem('ytdl_audio_folder_path'), config_api.getConfigItem('ytdl_video_folder_path'), config_api.getConfigItem('ytdl_subscriptions_base_path'), config_api.getConfigItem('ytdl_users_base_path') ] .filter(Boolean) .map(root => path.resolve(__dirname, root)); let thumbnailRoot = null; let thumbnailRelativePath = null; for (const root of configuredRoots) { const relativePath = path.relative(root, resolvedRequestedPath); if (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) { thumbnailRoot = root; thumbnailRelativePath = relativePath; break; } } if (!thumbnailRoot || !thumbnailRelativePath) { logger.warn(`Refusing thumbnail request outside allowed roots: ${requestedPath}`); res.sendStatus(403); return; } res.sendFile(thumbnailRelativePath, { root: thumbnailRoot }, (err) => { if (!err) return; if (res.headersSent) return; if (err.statusCode === 404) { res.sendStatus(404); return; } logger.error(err); res.sendStatus(500); }); }); // Downloads management app.post('/api/downloads', optionalJwt, async (req, res) => { const user_uid = req.isAuthenticated() ? req.user.uid : null; const uids = req.body.uids; const filter_obj = getScopedFilterByUser(user_uid); let downloads = await db_api.getRecords('download_queue', filter_obj); if (uids) downloads = downloads.filter(download => uids.includes(download['uid'])); res.send({downloads: downloads}); }); app.post('/api/download', optionalJwt, async (req, res) => { const download_uid = req.body.download_uid; const user_uid = req.isAuthenticated() ? req.user.uid : null; const filter_obj = {uid: download_uid, ...getScopedFilterByUser(user_uid)}; const download = await db_api.getRecord('download_queue', filter_obj); if (download) { res.send({download: download}); } else { res.send({download: null}); } }); app.post('/api/clearDownloads', optionalJwt, async (req, res) => { const user_uid = req.isAuthenticated() ? req.user.uid : null; const scoped_filter = getScopedFilterByUser(user_uid); const clear_finished = req.body.clear_finished; const clear_paused = req.body.clear_paused; const clear_errors = req.body.clear_errors; let success = true; if (clear_finished) success &= await db_api.removeAllRecords('download_queue', {finished: true, ...scoped_filter, error: null}); if (clear_paused) success &= await db_api.removeAllRecords('download_queue', {paused: true, ...scoped_filter}); if (clear_errors) success &= await db_api.removeAllRecords('download_queue', {error: {$ne: null}, ...scoped_filter}); res.send({success: success}); }); app.post('/api/clearDownload', optionalJwt, async (req, res) => { const download_uid = req.body.download_uid; const user_uid = req.isAuthenticated() ? req.user.uid : null; const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)}); if (!owned_download) { res.send({success: false}); return; } const success = await downloader_api.clearDownload(download_uid); res.send({success: success}); }); app.post('/api/pauseDownload', optionalJwt, async (req, res) => { const download_uid = req.body.download_uid; const user_uid = req.isAuthenticated() ? req.user.uid : null; const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)}); if (!owned_download) { res.send({success: false}); return; } const success = await downloader_api.pauseDownload(download_uid); res.send({success: success}); }); app.post('/api/pauseAllDownloads', optionalJwt, async (req, res) => { const user_uid = req.isAuthenticated() ? req.user.uid : null; let success = true; const all_running_downloads = await db_api.getRecords('download_queue', {paused: false, finished: false, ...getScopedFilterByUser(user_uid)}); for (let i = 0; i < all_running_downloads.length; i++) { success &= await downloader_api.pauseDownload(all_running_downloads[i]['uid']); } res.send({success: success}); }); app.post('/api/resumeDownload', optionalJwt, async (req, res) => { const download_uid = req.body.download_uid; const user_uid = req.isAuthenticated() ? req.user.uid : null; const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)}); if (!owned_download) { res.send({success: false}); return; } const success = await downloader_api.resumeDownload(download_uid); res.send({success: success}); }); app.post('/api/resumeAllDownloads', optionalJwt, async (req, res) => { const user_uid = req.isAuthenticated() ? req.user.uid : null; let success = true; const all_paused_downloads = await db_api.getRecords('download_queue', {paused: true, ...getScopedFilterByUser(user_uid), error: null}); for (let i = 0; i < all_paused_downloads.length; i++) { success &= await downloader_api.resumeDownload(all_paused_downloads[i]['uid']); } res.send({success: success}); }); app.post('/api/restartDownload', optionalJwt, async (req, res) => { const download_uid = req.body.download_uid; const user_uid = req.isAuthenticated() ? req.user.uid : null; const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)}); if (!owned_download) { res.send({success: false, new_download_uid: null}); return; } const new_download = await downloader_api.restartDownload(download_uid); res.send({success: !!new_download, new_download_uid: new_download ? new_download['uid'] : null}); }); app.post('/api/cancelDownload', optionalJwt, async (req, res) => { const download_uid = req.body.download_uid; const user_uid = req.isAuthenticated() ? req.user.uid : null; const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)}); if (!owned_download) { res.send({success: false}); return; } const success = await downloader_api.cancelDownload(download_uid); res.send({success: success}); }); // tasks app.post('/api/getTasks', optionalJwt, async (req, res) => { const tasks = await db_api.getRecords('tasks'); for (let task of tasks) { if (!tasks_api.TASKS[task['key']]) { logger.verbose(`Task ${task['key']} does not exist!`); continue; } if (task['schedule']) task['next_invocation'] = tasks_api.TASKS[task['key']]['job'].nextInvocation().getTime(); } res.send({tasks: tasks}); }); app.post('/api/resetTasks', optionalJwt, async (req, res) => { const tasks_keys = Object.keys(tasks_api.TASKS); for (let i = 0; i < tasks_keys.length; i++) { const task_key = tasks_keys[i]; tasks_api.TASKS[task_key]['job'] = null; } await db_api.removeAllRecords('tasks'); await tasks_api.setupTasks(); res.send({success: true}); }); app.post('/api/getTask', optionalJwt, async (req, res) => { const task_key = req.body.task_key; const task = await db_api.getRecord('tasks', {key: task_key}); if (task['schedule']) task['next_invocation'] = tasks_api.TASKS[task_key]['job'].nextInvocation().getTime(); res.send({task: task}); }); app.post('/api/runTask', optionalJwt, async (req, res) => { const task_key = req.body.task_key; const task = await db_api.getRecord('tasks', {key: task_key}); let success = true; if (task['running'] || task['confirming']) success = false; else await tasks_api.executeRun(task_key); res.send({success: success}); }); app.post('/api/confirmTask', optionalJwt, async (req, res) => { const task_key = req.body.task_key; const task = await db_api.getRecord('tasks', {key: task_key}); let success = true; if (task['running'] || task['confirming'] || !task['data']) success = false; else await tasks_api.executeConfirm(task_key); res.send({success: success}); }); app.post('/api/updateTaskSchedule', optionalJwt, async (req, res) => { const task_key = req.body.task_key; const new_schedule = req.body.new_schedule; await tasks_api.updateTaskSchedule(task_key, new_schedule); res.send({success: true}); }); app.post('/api/updateTaskData', optionalJwt, async (req, res) => { const task_key = req.body.task_key; const new_data = req.body.new_data; const success = await db_api.updateRecord('tasks', {key: task_key}, {data: new_data}); res.send({success: success}); }); app.post('/api/updateTaskOptions', optionalJwt, async (req, res) => { const task_key = req.body.task_key; const new_options = req.body.new_options; const success = await db_api.updateRecord('tasks', {key: task_key}, {options: new_options}); res.send({success: success}); }); app.post('/api/getDBBackups', optionalJwt, async (req, res) => { const backup_dir = path.join('appdata', 'db_backup'); fs.ensureDirSync(backup_dir); const db_backups = []; const candidate_backups = await utils.recFindByExt(backup_dir, 'bak', null, [], false); for (let i = 0; i < candidate_backups.length; i++) { const candidate_backup = candidate_backups[i]; // must have specific format if (candidate_backup.split('.').length - 1 !== 4) continue; const candidate_backup_path = candidate_backup; const stats = fs.statSync(candidate_backup_path); db_backups.push({ name: path.basename(candidate_backup), timestamp: parseInt(candidate_backup.split('.')[2]), size: stats.size, source: candidate_backup.includes('local') ? 'local' : 'remote' }); } db_backups.sort((a,b) => b.timestamp - a.timestamp); res.send({db_backups: db_backups}); }); app.post('/api/restoreDBBackup', optionalJwt, async (req, res) => { const file_name = req.body.file_name; const success = await db_api.restoreDB(file_name); res.send({success: success}); }); // logs management app.post('/api/logs', optionalJwt, async function(req, res) { let logs = null; let lines = req.body.lines; const logs_path = path.join('appdata', 'logs', 'combined.log') if (await fs.pathExists(logs_path)) { if (lines) logs = await read_last_lines.read(logs_path, lines); else logs = await fs.readFile(logs_path, 'utf8'); } else logger.error(`Failed to find logs file at the expected location: ${logs_path}`) res.send({ logs: logs, success: !!logs }); }); app.post('/api/clearAllLogs', optionalJwt, async function(req, res) { const logs_path = path.join('appdata', 'logs', 'combined.log'); const logs_err_path = path.join('appdata', 'logs', 'error.log'); let success = false; try { await Promise.all([ fs.writeFile(logs_path, ''), fs.writeFile(logs_err_path, '') ]) success = true; } catch(e) { logger.error(e); } res.send({ success: success }); }); app.post('/api/getFileFormats', optionalJwt, async (req, res) => { const url = req.body.url; const result = await downloader_api.getVideoInfoByURL(url); res.send({ result: result && result.length === 1 ? result[0] : null, success: result && result.length === 0 }) }); // user authentication app.get('/api/auth/oidc/status', (req, res) => { res.send(oidc_api.getStatus()); }); app.get('/api/auth/oidc/login', async (req, res) => { if (!oidc_api.isEnabled()) { res.status(404).send('OIDC is disabled.'); return; } try { const return_to = req.query.returnTo ? String(req.query.returnTo) : '/home'; const authorization_url = oidc_api.createAuthorizationURL(return_to); res.redirect(authorization_url); } catch (err) { logger.error(`OIDC login redirect failed: ${err.message}`); res.sendStatus(500); } }); app.get('/api/auth/oidc/callback', async (req, res) => { if (!oidc_api.isEnabled()) { res.status(404).send('OIDC is disabled.'); return; } try { const callback_result = await oidc_api.consumeAuthorizationCallback(req); const claims = callback_result.claims || {}; if (!oidc_api.isClaimsAllowed(claims)) { logger.error('OIDC login rejected: user does not match allowed groups policy.'); res.sendStatus(403); return; } const oidc_config = oidc_api.getConfiguration(); const user_obj = await auth_api.upsertOIDCUser(claims, { auto_register: oidc_config.auto_register, admin_claim: oidc_config.admin_claim, admin_value: oidc_config.admin_value, groups_claim: oidc_config.groups_claim, username_claim: oidc_config.username_claim, display_name_claim: oidc_config.display_name_claim }); if (!user_obj) { res.sendStatus(403); return; } const auth_response = await auth_api.getAuthResponseObject(user_obj); const return_to = callback_result.return_to ? callback_result.return_to : '/home'; const redirect_path = `${getOrigin()}/#/login;oidc_token=${encodeURIComponent(auth_response.token)};redirect=${encodeURIComponent(return_to)}`; res.setHeader('Set-Cookie', 'ytdl_oidc_bootstrap=1; Path=/; Max-Age=60; HttpOnly; SameSite=Lax'); res.redirect(redirect_path); } catch (err) { logger.error(`OIDC callback failed: ${err.message}`); res.sendStatus(401); } }); app.post('/api/auth/register', optionalJwt, async (req, res) => { if (oidc_api.isEnabled()) { res.status(403).send('Registration is disabled when OIDC is enabled.'); return; } const userid = req.body.userid; const username = req.body.username; const plaintextPassword = req.body.password; if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) { logger.error(`Registration failed for user ${userid}. Registration is disabled.`); res.sendStatus(409); return; } if (plaintextPassword === "") { logger.error(`Registration failed for user ${userid}. A password must be provided.`); res.sendStatus(409); return; } if (!userid || !username) { logger.error(`Registration failed for user ${userid}. Username or userid is invalid.`); } const new_user = await auth_api.registerUser(userid, username, plaintextPassword); if (!new_user) { res.sendStatus(409); return; } res.send({ user: new_user }); }); app.post('/api/auth/login' , (req, res, next) => { if (oidc_api.isEnabled()) { res.status(403).send('Password login is disabled when OIDC is enabled.'); return; } next(); } , auth_api.passport.authenticate(['local', 'ldapauth'], { session: false }) , auth_api.generateJWT , auth_api.returnAuthResponse ); app.post('/api/auth/jwtAuth' , auth_api.passport.authenticate('jwt', { session: false }) , auth_api.passport.authorize('jwt') , auth_api.generateJWT , auth_api.returnAuthResponse ); app.post('/api/auth/changePassword', optionalJwt, async (req, res) => { let user_uid = req.body.user_uid; let password = req.body.new_password; let success = await auth_api.changeUserPassword(user_uid, password); res.send({success: success}); }); app.post('/api/auth/adminExists', async (req, res) => { let exists = await auth_api.adminExists(); res.send({exists: exists}); }); // user management app.post('/api/getUsers', optionalJwt, async (req, res) => { let users = await db_api.getRecords('users'); res.send({users: users}); }); app.post('/api/getRoles', optionalJwt, async (req, res) => { let roles = await db_api.getRecords('roles'); res.send({roles: roles}); }); app.post('/api/updateUser', optionalJwt, async (req, res) => { let change_obj = req.body.change_object; try { if (change_obj.name) { await db_api.updateRecord('users', {uid: change_obj.uid}, {name: change_obj.name}); } if (change_obj.role) { await db_api.updateRecord('users', {uid: change_obj.uid}, {role: change_obj.role}); } res.send({success: true}); } catch (err) { logger.error(err); res.send({success: false}); } }); app.post('/api/deleteUser', optionalJwt, async (req, res) => { let uid = req.body.uid; try { const success = await auth_api.deleteUser(uid); res.send({success: success}); } catch (err) { logger.error(err); res.send({success: false}); } }); app.post('/api/changeUserPermissions', optionalJwt, async (req, res) => { const user_uid = req.body.user_uid; const permission = req.body.permission; const new_value = req.body.new_value; if (!permission || !new_value) { res.sendStatus(400); return; } const success = await auth_api.changeUserPermissions(user_uid, permission, new_value); res.send({success: success}); }); app.post('/api/changeRolePermissions', optionalJwt, async (req, res) => { const role = req.body.role; const permission = req.body.permission; const new_value = req.body.new_value; if (!permission || !new_value) { res.sendStatus(400); return; } const success = await auth_api.changeRolePermissions(role, permission, new_value); res.send({success: success}); }); // notifications app.post('/api/getNotifications', optionalJwt, async (req, res) => { const uuid = req.isAuthenticated() ? req.user.uid : null; const notifications = await db_api.getRecords('notifications', {user_uid: uuid}); res.send({notifications: notifications}); }); // set notifications to read app.post('/api/setNotificationsToRead', optionalJwt, async (req, res) => { const uuid = req.isAuthenticated() ? req.user.uid : null; const success = await db_api.updateRecords('notifications', {user_uid: uuid}, {read: true}); res.send({success: success}); }); app.post('/api/deleteNotification', optionalJwt, async (req, res) => { const uid = req.isAuthenticated() ? req.user.uid : null; const success = await db_api.removeRecord('notifications', {uid: uid}); res.send({success: success}); }); app.post('/api/deleteAllNotifications', optionalJwt, async (req, res) => { const uuid = req.isAuthenticated() ? req.user.uid : null; const success = await db_api.removeAllRecords('notifications', {user_uid: uuid}); res.send({success: success}); }); app.post('/api/telegramRequest', async (req, res) => { if (!req.body.message && !req.body.message.text) { logger.error('Invalid Telegram request received!'); res.sendStatus(400); return; } const text = req.body.message.text; const regex_exp = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)?/gi; const url_regex = new RegExp(regex_exp); const matched_urls = text.match(url_regex); if (matched_urls && matched_urls.length) { let parsed_url = null; try { parsed_url = new URL(matched_urls[0]); } catch { parsed_url = null; } if (!parsed_url || (parsed_url.protocol !== 'http:' && parsed_url.protocol !== 'https:')) { logger.error('Invalid Telegram request received! URL protocol is not allowed.'); res.sendStatus(400); return; } downloader_api.createDownload(parsed_url.toString(), 'video', {}, req.query.user_uid ? req.query.user_uid : null); res.sendStatus(200); } else { logger.error('Invalid Telegram request received! Make sure you only send a valid URL.'); notifications_api.sendTelegramNotification({title: 'Invalid Telegram Request', body: 'Make sure you only send a valid URL.', url: text}); res.sendStatus(400); } }); // rss feed app.get('/api/rss', async function (req, res) { if (!config_api.getConfigItem('ytdl_enable_rss_feed')) { logger.error('RSS feed is disabled! It must be enabled in the settings before it can be generated.'); res.sendStatus(403); return; } // these are returned const sort = req.query.sort ? JSON.parse(decodeURIComponent(req.query.sort)) : {by: 'registered', order: -1}; const range = req.query.range ? req.query.range.map(range_num => parseInt(range_num)) : null; const text_search = req.query.text_search ? decodeURIComponent(req.query.text_search) : null; const file_type_filter = req.query.file_type_filter; const favorite_filter = req.query.favorite_filter === 'true'; const sub_id = req.query.sub_id ? decodeURIComponent(req.query.sub_id) : null; const uuid = req.query.uuid ? decodeURIComponent(req.query.uuid) : null; const {files} = await files_api.getAllFiles(sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid); const { Feed } = await import('feed'); const feed = new Feed({ title: 'Downloads', description: 'YoutubeDL-Material downloads', id: utils.getBaseURL(), link: utils.getBaseURL(), image: 'https://github.com/voc0der/YoutubeDL-Material/blob/master/src/assets/images/logo_128px.png', favicon: 'https://raw.githubusercontent.com/voc0der/YoutubeDL-Material/master/src/favicon.ico', generator: 'YoutubeDL-Material' }); files.forEach(file => { feed.addItem({ title: file.title, link: `${utils.getBaseURL()}/#/player;uid=${file.uid}`, description: file.description, author: [ { name: file.uploader, link: file.url } ], contributor: [], date: file.timestamp, // https://stackoverflow.com/a/45415677/8088021 image: escapeXmlEntities(file.thumbnailURL) }); }); res.send(feed.rss2()); }); // web server app.use(function(req, res, next) { if (!oidc_api.isEnabled() || !config_api.getConfigItem('ytdl_multi_user_mode')) { return next(); } if (req.path.includes('/api/')) { return next(); } const accept = req.accepts('html', 'json', 'xml'); if (accept !== 'html') { return next(); } const ext = path.extname(req.path); if (ext !== '') { return next(); } const cookie_header = req.headers.cookie || ''; const has_bootstrap_cookie = cookie_header.split(';').map(cookie => cookie.trim()).includes('ytdl_oidc_bootstrap=1'); if (has_bootstrap_cookie) { res.setHeader('Set-Cookie', 'ytdl_oidc_bootstrap=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax'); return next(); } const return_to = req.path && req.path !== '/' ? req.path : '/home'; const redirect_path = `/api/auth/oidc/login?returnTo=${encodeURIComponent(return_to)}`; return res.redirect(redirect_path); }); app.use(function(req, res, next) { //if the request is not html then move along var accept = req.accepts('html', 'json', 'xml'); if (accept !== 'html') { return next(); } // if the request has a '.' assume that it's for a file, move along var ext = path.extname(req.path); if (ext !== '') { return next(); } let index_path = path.join(__dirname, 'public', 'index.html'); res.setHeader('Content-Type', 'text/html'); fs.createReadStream(index_path).pipe(res); }); let public_dir = path.join(__dirname, 'public'); app.use(express.static(public_dir));