diff --git a/.gitignore b/.gitignore index 935896d..1ba6f18 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ backend/appdata/archives/blacklist_audio.txt backend/appdata/archives/blacklist_video.txt backend/appdata/logs/combined.log backend/appdata/logs/error.log +backend/appdata/users.json diff --git a/backend/app.js b/backend/app.js index dd25feb..1376471 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1,6 +1,7 @@ var async = require('async'); const { uuid } = require('uuidv4'); var fs = require('fs-extra'); +var auth_api = require('./authentication/auth'); var winston = require('winston'); var path = require('path'); var youtubedl = require('youtube-dl'); @@ -31,9 +32,13 @@ var app = express(); // 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); + // check if debug mode let debugMode = process.env.YTDL_MODE === 'debug'; @@ -61,7 +66,8 @@ const logger = winston.createLogger({ }); config_api.setLogger(logger); -subscriptions_api.initialize(db, logger); +subscriptions_api.initialize(db, users_db, logger); +auth_api.initialize(users_db, logger); // var GithubContent = require('github-content'); @@ -83,6 +89,30 @@ db.defaults( files_to_db_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 var frontendUrl = null; var backendUrl = null; @@ -147,11 +177,13 @@ if (writeConfigMode) { } var downloads = {}; -var descriptors = {}; app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json()); +// use passport +app.use(auth_api.passport.initialize()); + // objects function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date) { @@ -214,6 +246,7 @@ async function runFilesToDBMigration() { db.set('files_to_db_migration_complete', true).write(); resolve(true); } catch(err) { + logger.error(err); resolve(false); } }); @@ -318,7 +351,9 @@ async function downloadReleaseFiles(tag) { fs.mkdirSync(path.join(__dirname, 'public')); let replace_ignore_list = ['youtubedl-material/appdata/default.json', - 'youtubedl-material/appdata/db.json'] + 'youtubedl-material/appdata/db.json', + 'youtubedl-material/appdata/users.json', + 'youtubedl-material/appdata/*'] logger.info(`Installing update ${tag}...`) // downloads new package.json and adds new public dir files from the downloaded zip @@ -571,7 +606,18 @@ function calculateSubcriptionRetrievalDelay(amount) { } function watchSubscriptions() { - let subscriptions = subscriptions_api.getAllSubscriptions(); + let subscriptions = null; + + const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); + if (multiUserMode) { + subscriptions = []; + let users = users_db.get('users').value(); + for (let i = 0; i < users.length; i++) { + if (users[i]['subscriptions']) subscriptions = subscriptions.concat(users[i]['subscriptions']); + } + } else { + subscriptions = subscriptions_api.getAllSubscriptions(); + } if (!subscriptions) return; @@ -583,7 +629,7 @@ function watchSubscriptions() { let sub = subscriptions[i]; logger.verbose('Watching ' + sub.name + ' with delay interval of ' + delay_interval); setTimeout(() => { - subscriptions_api.getVideosForSub(sub); + subscriptions_api.getVideosForSub(sub, sub.user_uid); }, current_delay); current_delay += delay_interval; if (current_delay >= subscriptionsCheckInterval * 1000) current_delay = 0; @@ -631,7 +677,7 @@ function getMp3s() { var url = jsonobj.webpage_url; var uploader = jsonobj.uploader; var upload_date = jsonobj.upload_date; - upload_date = `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}`; + upload_date = upload_date ? `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}` : null; var size = stats.size; @@ -660,7 +706,7 @@ function getMp4s(relative_path = true) { var url = jsonobj.webpage_url; var uploader = jsonobj.uploader; var upload_date = jsonobj.upload_date; - upload_date = `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}`; + upload_date = upload_date ? `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}` : null; var thumbnail = jsonobj.thumbnail; var duration = jsonobj.duration; @@ -719,10 +765,16 @@ function getFileSizeMp4(name) return filesize; } -function getJSONMp3(name, openReadPerms = false) +function getJSONMp3(name, customPath = null, openReadPerms = false) { var jsonPath = audioFolderPath+name+".info.json"; var alternateJsonPath = audioFolderPath+name+".mp3.info.json"; + if (!customPath) { + jsonPath = audioFolderPath + name + ".info.json"; + } else { + jsonPath = customPath + name + ".info.json"; + alternateJsonPath = customPath + name + ".mp3.info.json"; + } var obj = null; if (fs.existsSync(jsonPath)) { obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); @@ -868,10 +920,10 @@ async function deleteAudioFile(name, blacklistMode = false) { let audioFileExists = fs.existsSync(audioFilePath); - if (descriptors[name]) { + if (config_api.descriptors[name]) { try { - for (let i = 0; i < descriptors[name].length; i++) { - descriptors[name][i].destroy(); + for (let i = 0; i < config_api.descriptors[name].length; i++) { + config_api.descriptors[name][i].destroy(); } } catch(e) { @@ -926,10 +978,10 @@ async function deleteVideoFile(name, customPath = null, blacklistMode = false) { jsonExists = fs.existsSync(jsonPath); videoFileExists = fs.existsSync(videoFilePath); - if (descriptors[name]) { + if (config_api.descriptors[name]) { try { - for (let i = 0; i < descriptors[name].length; i++) { - descriptors[name][i].destroy(); + for (let i = 0; i < config_api.descriptors[name].length; i++) { + config_api.descriptors[name][i].destroy(); } } catch(e) { @@ -997,9 +1049,9 @@ function recFindByExt(base,ext,files,result) return result } -function registerFileDB(full_file_path, type) { - const file_id = full_file_path.substring(0, full_file_path.length-4); - const file_object = generateFileObject(file_id, type); +function registerFileDB(file_path, type, multiUserMode = null) { + const file_id = file_path.substring(0, file_path.length-4); + const file_object = generateFileObject(file_id, type, multiUserMode && multiUserMode.file_path); if (!file_object) { logger.error(`Could not find associated JSON file for ${type} file ${file_id}`); return false; @@ -1011,20 +1063,25 @@ function registerFileDB(full_file_path, type) { path_object = path.parse(file_object['path']); file_object['path'] = path.format(path_object); - // remove existing video if overwriting - db.get(`files.${type}`) + if (multiUserMode) { + auth_api.registerUserFile(multiUserMode.user, file_object, type); + } else { + // remove existing video if overwriting + db.get(`files.${type}`) .remove({ path: file_object['path'] }).write(); - db.get(`files.${type}`) - .push(file_object) - .write(); + db.get(`files.${type}`) + .push(file_object) + .write(); + } + return file_object['uid']; } -function generateFileObject(id, type) { - var jsonobj = (type === 'audio') ? getJSONMp3(id, true) : getJSONMp4(id, null, true); +function generateFileObject(id, type, customPath = null) { + var jsonobj = (type === 'audio') ? getJSONMp3(id, customPath, true) : getJSONMp4(id, customPath, true); if (!jsonobj) { return null; } @@ -1108,6 +1165,20 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) { var is_audio = type === 'audio'; var ext = is_audio ? '.mp3' : '.mp4'; var fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath; + + // prepend with user if needed + let multiUserMode = null; + if (options.user) { + let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + const user_path = path.join(usersFileFolder, options.user, type); + fs.ensureDirSync(user_path); + fileFolderPath = user_path + path.sep; + multiUserMode = { + user: options.user, + file_path: fileFolderPath + } + options.customFileFolderPath = fileFolderPath; + } const downloadConfig = await generateArgs(url, type, options); @@ -1189,7 +1260,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) { } // registers file in DB - file_uid = registerFileDB(full_file_path.substring(fileFolderPath.length, full_file_path.length), type); + file_uid = registerFileDB(full_file_path.substring(fileFolderPath.length, full_file_path.length), type, multiUserMode); if (file_name) file_names.push(file_name); } @@ -1224,6 +1295,20 @@ async function downloadFileByURL_normal(url, type, options, sessionID = null) { var file_uid = null; var fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath; + // prepend with user if needed + let multiUserMode = null; + if (options.user) { + let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + const user_path = path.join(usersFileFolder, options.user, type); + fs.ensureDirSync(user_path); + fileFolderPath = user_path + path.sep; + multiUserMode = { + user: options.user, + file_path: fileFolderPath + } + options.customFileFolderPath = fileFolderPath; + } + const downloadConfig = await generateArgs(url, type, options); // adds download to download helper @@ -1300,12 +1385,12 @@ async function downloadFileByURL_normal(url, type, options, sessionID = null) { // registers file in DB const base_file_name = video_info._filename.substring(fileFolderPath.length, video_info._filename.length); - file_uid = registerFileDB(base_file_name, type); + file_uid = registerFileDB(base_file_name, type, multiUserMode); if (options.merged_string) { let current_merged_archive = fs.readFileSync(fileFolderPath + 'merged.txt', 'utf8'); let diff = current_merged_archive.replace(options.merged_string, ''); - const archive_path = path.join(archivePath, 'archive_video.txt'); + const archive_path = req.isAuthenticated() ? path.join(fileFolderPath, 'archives', `archive_${type}.txt`) : path.join(archivePath, `archive_${type}.txt`); fs.appendFileSync(archive_path, diff); } @@ -1338,6 +1423,8 @@ async function generateArgs(url, type, options) { var fileFolderPath = is_audio ? audioFolderPath : videoFolderPath; + if (options.customFileFolderPath) fileFolderPath = options.customFileFolderPath; + var customArgs = options.customArgs; var customOutput = options.customOutput; var customQualityConfiguration = options.customQualityConfiguration; @@ -1363,17 +1450,17 @@ async function generateArgs(url, type, options) { downloadConfig = customArgs.split(' '); } else { if (customQualityConfiguration) { - qualityPath = customQualityConfiguration; + qualityPath = `-f ${customQualityConfiguration}`; } else if (selectedHeight && selectedHeight !== '' && !is_audio) { - qualityPath = `-f bestvideo[height=${selectedHeight}]+bestaudio/best[height=${selectedHeight}]`; + qualityPath = `-f '(mp4)[height=${selectedHeight}]'`; } else if (maxBitrate && is_audio) { qualityPath = `--audio-quality ${maxBitrate}` } if (customOutput) { - downloadConfig = ['-o', fileFolderPath + customOutput + "", qualityPath, '--write-info-json', '--print-json']; + downloadConfig = ['-o', path.join(fileFolderPath, customOutput), qualityPath, '--write-info-json', '--print-json']; } else { - downloadConfig = ['-o', fileFolderPath + videopath + (is_audio ? '.%(ext)s' : '.mp4'), qualityPath, '--write-info-json', '--print-json']; + downloadConfig = ['-o', path.join(fileFolderPath, videopath + (is_audio ? '.%(ext)s' : '.mp4')), qualityPath, '--write-info-json', '--print-json']; } if (is_audio) { @@ -1391,13 +1478,13 @@ async function generateArgs(url, type, options) { let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive'); if (useYoutubeDLArchive) { - const archive_path = path.join(archivePath, `archive_${type}.txt`); + const archive_path = options.user ? path.join(fileFolderPath, 'archives', `archive_${type}.txt`) : path.join(archivePath, `archive_${type}.txt`); // create archive file if it doesn't exist if (!fs.existsSync(archive_path)) { fs.closeSync(fs.openSync(archive_path, 'w')); } - let blacklist_path = path.join(archivePath, `blacklist_${type}.txt`); + let blacklist_path = options.user ? path.join(fileFolderPath, 'archives', `blacklist_${type}.txt`) : path.join(archivePath, `blacklist_${type}.txt`); // create blacklist file if it doesn't exist if (!fs.existsSync(blacklist_path)) { fs.closeSync(fs.openSync(blacklist_path, 'w')); @@ -1648,12 +1735,36 @@ app.use(function(req, res, next) { } else if (req.path.includes('/api/video/') || req.path.includes('/api/audio/')) { next(); } else { + logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`); req.socket.end(); } }); app.use(compression()); +const optionalJwt = 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/audio') || + req.path.includes('/api/video') || + req.path.includes('/api/downloadFile'))) { + // 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 type = using_body ? req.body.type : req.query.type; + const is_shared = auth_api.getUserVideo(uuid, uid, type, true); + if (is_shared) return next(); + } else if (multiUserMode) { + 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({ @@ -1680,7 +1791,7 @@ app.get('/api/using-encryption', function(req, res) { res.send(usingEncryption); }); -app.post('/api/tomp3', async function(req, res) { +app.post('/api/tomp3', optionalJwt, async function(req, res) { var url = req.body.url; var options = { customArgs: req.body.customArgs, @@ -1689,7 +1800,8 @@ app.post('/api/tomp3', async function(req, res) { customQualityConfiguration: req.body.customQualityConfiguration, youtubeUsername: req.body.youtubeUsername, youtubePassword: req.body.youtubePassword, - ui_uid: req.body.ui_uid + ui_uid: req.body.ui_uid, + user: req.isAuthenticated() ? req.user.uid : null } const is_playlist = url.includes('playlist'); @@ -1706,7 +1818,7 @@ app.post('/api/tomp3', async function(req, res) { res.end("yes"); }); -app.post('/api/tomp4', async function(req, res) { +app.post('/api/tomp4', optionalJwt, async function(req, res) { var url = req.body.url; var options = { customArgs: req.body.customArgs, @@ -1715,12 +1827,13 @@ app.post('/api/tomp4', async function(req, res) { customQualityConfiguration: req.body.customQualityConfiguration, youtubeUsername: req.body.youtubeUsername, youtubePassword: req.body.youtubePassword, - ui_uid: req.body.ui_uid + ui_uid: req.body.ui_uid, + user: req.isAuthenticated() ? req.user.uid : null } const is_playlist = url.includes('playlist'); let result_obj = null; - if (is_playlist) + if (is_playlist || options.customQualityConfiguration) result_obj = await downloadFileByURL_exec(url, 'video', options, req.query.sessionID); else result_obj = await downloadFileByURL_normal(url, 'video', options, req.query.sessionID); @@ -1776,46 +1889,67 @@ app.post('/api/fileStatusMp4', function(req, res) { }); // gets all download mp3s -app.get('/api/getMp3s', function(req, res) { +app.get('/api/getMp3s', optionalJwt, function(req, res) { var mp3s = db.get('files.audio').value(); // getMp3s(); var playlists = db.get('playlists.audio').value(); + const is_authenticated = req.isAuthenticated(); + if (is_authenticated) { + // get user audio files/playlists + auth_api.passport.authenticate('jwt') + mp3s = auth_api.getUserVideos(req.user.uid, 'audio'); + playlists = auth_api.getUserPlaylists(req.user.uid, 'audio'); + } res.send({ mp3s: mp3s, playlists: playlists }); - res.end("yes"); }); // gets all download mp4s -app.get('/api/getMp4s', function(req, res) { +app.get('/api/getMp4s', optionalJwt, function(req, res) { var mp4s = db.get('files.video').value(); // getMp4s(); var playlists = db.get('playlists.video').value(); + const is_authenticated = req.isAuthenticated(); + if (is_authenticated) { + // get user videos/playlists + auth_api.passport.authenticate('jwt') + mp4s = auth_api.getUserVideos(req.user.uid, 'video'); + playlists = auth_api.getUserPlaylists(req.user.uid, 'video'); + } + res.send({ mp4s: mp4s, playlists: playlists }); - res.end("yes"); }); -app.post('/api/getFile', function (req, res) { +app.post('/api/getFile', optionalJwt, function (req, res) { var uid = req.body.uid; var type = req.body.type; + var uuid = req.body.uuid; var file = null; - if (!type) { - file = db.get('files.audio').find({uid: uid}).value(); - if (!file) { - file = db.get('files.video').find({uid: uid}).value(); - if (file) type = 'video'; - } else { - type = 'audio'; + if (req.isAuthenticated()) { + file = auth_api.getUserVideo(req.user.uid, uid, type); + } else if (uuid) { + file = auth_api.getUserVideo(uuid, uid, type, true); + } else { + if (!type) { + file = db.get('files.audio').find({uid: uid}).value(); + if (!file) { + file = db.get('files.video').find({uid: uid}).value(); + if (file) type = 'video'; + } else { + type = 'audio'; + } } + + if (!file && type) file = db.get(`files.${type}`).find({uid: uid}).value(); } - - if (!file && type) db.get(`files.${type}`).find({uid: uid}).value(); + if (file) { res.send({ @@ -1830,10 +1964,21 @@ app.post('/api/getFile', function (req, res) { }); // video sharing -app.post('/api/enableSharing', function(req, res) { +app.post('/api/enableSharing', optionalJwt, function(req, res) { var type = req.body.type; 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, type, is_playlist, true); + console.log(success); + res.send({success: success}); + return; + } + + // single-user mode try { success = true; if (!is_playlist && type !== 'subscription') { @@ -1863,10 +2008,20 @@ app.post('/api/enableSharing', function(req, res) { }); }); -app.post('/api/disableSharing', function(req, res) { +app.post('/api/disableSharing', optionalJwt, function(req, res) { var type = req.body.type; var uid = req.body.uid; var is_playlist = req.body.is_playlist; + + // multi-user mode + if (req.isAuthenticated()) { + // if multi user mode, use this method instead + success = auth_api.changeSharingMode(req.user.uid, uid, type, is_playlist, false); + res.send({success: success}); + return; + } + + // single-user mode try { success = true; if (!is_playlist && type !== 'subscription') { @@ -1896,17 +2051,19 @@ app.post('/api/disableSharing', function(req, res) { }); }); -app.post('/api/subscribe', async (req, res) => { +app.post('/api/subscribe', optionalJwt, async (req, res) => { let name = req.body.name; let url = req.body.url; let timerange = req.body.timerange; let streamingOnly = req.body.streamingOnly; + let user_uid = req.isAuthenticated() ? req.user.uid : null; const new_sub = { name: name, url: url, id: uuid(), - streamingOnly: streamingOnly + streamingOnly: streamingOnly, + user_uid: user_uid }; // adds timerange if it exists, otherwise all videos will be downloaded @@ -1914,7 +2071,7 @@ app.post('/api/subscribe', async (req, res) => { new_sub.timerange = timerange; } - const result_obj = await subscriptions_api.subscribe(new_sub); + const result_obj = await subscriptions_api.subscribe(new_sub, user_uid); if (result_obj.success) { res.send({ @@ -1928,11 +2085,12 @@ app.post('/api/subscribe', async (req, res) => { } }); -app.post('/api/unsubscribe', async (req, res) => { +app.post('/api/unsubscribe', optionalJwt, async (req, res) => { let deleteMode = req.body.deleteMode let sub = req.body.sub; + let user_uid = req.isAuthenticated() ? req.user.uid : null; - let result_obj = subscriptions_api.unsubscribe(sub, deleteMode); + let result_obj = subscriptions_api.unsubscribe(sub, deleteMode, user_uid); if (result_obj.success) { res.send({ success: result_obj.success @@ -1945,12 +2103,13 @@ app.post('/api/unsubscribe', async (req, res) => { } }); -app.post('/api/deleteSubscriptionFile', async (req, res) => { +app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => { let deleteForever = req.body.deleteForever; let file = req.body.file; let sub = req.body.sub; + let user_uid = req.isAuthenticated() ? req.user.uid : null; - let success = await subscriptions_api.deleteSubscriptionFile(sub, file, deleteForever); + let success = await subscriptions_api.deleteSubscriptionFile(sub, file, deleteForever, user_uid); if (success) { res.send({ @@ -1962,11 +2121,12 @@ app.post('/api/deleteSubscriptionFile', async (req, res) => { }); -app.post('/api/getSubscription', async (req, res) => { +app.post('/api/getSubscription', optionalJwt, async (req, res) => { let subID = req.body.id; + let user_uid = req.isAuthenticated() ? req.user.uid : null; // get sub from db - let subscription = subscriptions_api.getSubscription(subID); + let subscription = subscriptions_api.getSubscription(subID, user_uid); if (!subscription) { // failed to get subscription from db, send 400 error @@ -1976,7 +2136,12 @@ app.post('/api/getSubscription', async (req, res) => { // get sub videos if (subscription.name && !subscription.streamingOnly) { - let base_path = config_api.getConfigItem('ytdl_subscriptions_base_path'); + let base_path = null; + if (user_uid) + base_path = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); + else + base_path = config_api.getConfigItem('ytdl_subscriptions_base_path'); + let appended_base_path = path.join(base_path, subscription.isPlaylist ? 'playlists' : 'channels', subscription.name, '/'); let files; try { @@ -2033,25 +2198,29 @@ app.post('/api/getSubscription', async (req, res) => { } }); -app.post('/api/downloadVideosForSubscription', async (req, res) => { +app.post('/api/downloadVideosForSubscription', optionalJwt, async (req, res) => { let subID = req.body.subID; - let sub = subscriptions_api.getSubscription(subID); - subscriptions_api.getVideosForSub(sub); + let user_uid = req.isAuthenticated() ? req.user.uid : null; + + let sub = subscriptions_api.getSubscription(subID, user_uid); + subscriptions_api.getVideosForSub(sub, user_uid); res.send({ success: true }); }); -app.post('/api/getAllSubscriptions', async (req, res) => { +app.post('/api/getAllSubscriptions', optionalJwt, async (req, res) => { + let user_uid = req.isAuthenticated() ? req.user.uid : null; + // get subs from api - let subscriptions = subscriptions_api.getAllSubscriptions(); + let subscriptions = subscriptions_api.getAllSubscriptions(user_uid); res.send({ subscriptions: subscriptions }); }); -app.post('/api/createPlaylist', async (req, res) => { +app.post('/api/createPlaylist', optionalJwt, async (req, res) => { let playlistName = req.body.playlistName; let fileNames = req.body.fileNames; let type = req.body.type; @@ -2065,9 +2234,14 @@ app.post('/api/createPlaylist', async (req, res) => { type: type }; - db.get(`playlists.${type}`) - .push(new_playlist) - .write(); + if (req.isAuthenticated()) { + auth_api.addPlaylist(req.user.uid, new_playlist, type); + } else { + db.get(`playlists.${type}`) + .push(new_playlist) + .write(); + } + res.send({ new_playlist: new_playlist, @@ -2075,24 +2249,29 @@ app.post('/api/createPlaylist', async (req, res) => { }) }); -app.post('/api/getPlaylist', async (req, res) => { +app.post('/api/getPlaylist', optionalJwt, async (req, res) => { let playlistID = req.body.playlistID; let type = req.body.type; let playlist = null; - if (!type) { - playlist = db.get('playlists.audio').find({id: playlistID}).value(); - if (!playlist) { - playlist = db.get('playlists.video').find({id: playlistID}).value(); - if (playlist) type = 'video'; - } else { - type = 'audio'; + if (req.isAuthenticated()) { + playlist = auth_api.getUserPlaylist(req.user.uid, playlistID, type); + type = playlist.type; + } else { + if (!type) { + playlist = db.get('playlists.audio').find({id: playlistID}).value(); + if (!playlist) { + playlist = db.get('playlists.video').find({id: playlistID}).value(); + if (playlist) type = 'video'; + } else { + type = 'audio'; + } } + + if (!playlist) playlist = db.get(`playlists.${type}`).find({id: playlistID}).value(); } - if (!playlist) playlist = db.get(`playlists.${type}`).find({id: playlistID}).value(); - res.send({ playlist: playlist, type: type, @@ -2100,22 +2279,22 @@ app.post('/api/getPlaylist', async (req, res) => { }); }); -app.post('/api/updatePlaylist', async (req, res) => { +app.post('/api/updatePlaylist', optionalJwt, async (req, res) => { let playlistID = req.body.playlistID; let fileNames = req.body.fileNames; let type = req.body.type; let success = false; try { - db.get(`playlists.${type}`) - .find({id: playlistID}) - .assign({fileNames: fileNames}) - .write(); - /*logger.info('success!'); - let new_val = db.get(`playlists.${type}`) - .find({id: playlistID}) - .value(); - logger.info(new_val);*/ + if (req.isAuthenticated()) { + auth_api.updatePlaylist(req.user.uid, playlistID, fileNames, type); + } else { + db.get(`playlists.${type}`) + .find({id: playlistID}) + .assign({fileNames: fileNames}) + .write(); + } + success = true; } catch(e) { logger.error(`Failed to find playlist with ID ${playlistID}`); @@ -2126,16 +2305,20 @@ app.post('/api/updatePlaylist', async (req, res) => { }) }); -app.post('/api/deletePlaylist', async (req, res) => { +app.post('/api/deletePlaylist', optionalJwt, async (req, res) => { let playlistID = req.body.playlistID; let type = req.body.type; let success = null; try { - // removes playlist from playlists - db.get(`playlists.${type}`) - .remove({id: playlistID}) - .write(); + if (req.isAuthenticated()) { + auth_api.removePlaylist(req.user.uid, playlistID, type); + } else { + // removes playlist from playlists + db.get(`playlists.${type}`) + .remove({id: playlistID}) + .write(); + } success = true; } catch(e) { @@ -2148,12 +2331,19 @@ app.post('/api/deletePlaylist', async (req, res) => { }); // deletes mp3 file -app.post('/api/deleteMp3', async (req, res) => { +app.post('/api/deleteMp3', optionalJwt, async (req, res) => { // var name = req.body.name; var uid = req.body.uid; + var blacklistMode = req.body.blacklistMode; + + if (req.isAuthenticated()) { + let success = auth_api.deleteUserFile(req.user.uid, uid, 'audio', blacklistMode); + res.send(success); + return; + } + var audio_obj = db.get('files.audio').find({uid: uid}).value(); var name = audio_obj.id; - var blacklistMode = req.body.blacklistMode; var fullpath = audioFolderPath + name + ".mp3"; var wasDeleted = false; if (fs.existsSync(fullpath)) @@ -2174,11 +2364,18 @@ app.post('/api/deleteMp3', async (req, res) => { }); // deletes mp4 file -app.post('/api/deleteMp4', async (req, res) => { +app.post('/api/deleteMp4', optionalJwt, async (req, res) => { var uid = req.body.uid; + var blacklistMode = req.body.blacklistMode; + + if (req.isAuthenticated()) { + let success = auth_api.deleteUserFile(req.user.uid, uid, 'video', blacklistMode); + res.send(success); + return; + } + var video_obj = db.get('files.video').find({uid: uid}).value(); var name = video_obj.id; - var blacklistMode = req.body.blacklistMode; var fullpath = videoFolderPath + name + ".mp4"; var wasDeleted = false; if (fs.existsSync(fullpath)) @@ -2199,32 +2396,36 @@ app.post('/api/deleteMp4', async (req, res) => { } }); -app.post('/api/downloadFile', async (req, res) => { +app.post('/api/downloadFile', optionalJwt, async (req, res) => { let fileNames = req.body.fileNames; let zip_mode = req.body.zip_mode; let type = req.body.type; let outputName = req.body.outputName; let fullPathProvided = req.body.fullPathProvided; let subscriptionName = req.body.subscriptionName; - let subscriptionPlaylist = req.body.subscriptionPlaylist; + let subscriptionPlaylist = req.body.subPlaylist; let file = null; if (!zip_mode) { fileNames = decodeURIComponent(fileNames); - if (type === 'audio') { - if (!subscriptionName) { - file = path.join(__dirname, audioFolderPath, fileNames + '.mp3'); - } else { - let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); - file = path.join(__dirname, basePath, (subscriptionPlaylist ? 'playlists' : 'channels'), subscriptionName, fileNames + '.mp3') - } + const is_audio = type === 'audio'; + const fileFolderPath = is_audio ? audioFolderPath : videoFolderPath; + const ext = is_audio ? '.mp3' : '.mp4'; + + let base_path = fileFolderPath; + let usersFileFolder = null; + if (req.isAuthenticated()) { + usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + base_path = path.join(usersFileFolder, req.user.uid, type); + } + if (!subscriptionName) { + file = path.join(__dirname, base_path, fileNames + ext); } else { - // if type is 'subscription' or 'video', it's a video - if (!subscriptionName) { - file = path.join(__dirname, videoFolderPath, fileNames + '.mp4'); - } else { - let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); - file = path.join(__dirname, basePath, (subscriptionPlaylist ? 'playlists' : 'channels'), subscriptionName, fileNames + '.mp4') - } + let basePath = null; + if (usersFileFolder) + basePath = path.join(usersFileFolder, req.user.uid, 'subscriptions'); + else + basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + file = path.join(__dirname, basePath, (subscriptionPlaylist === 'true' ? 'playlists' : 'channels'), subscriptionName, fileNames + '.mp4') } } else { for (let i = 0; i < fileNames.length; i++) { @@ -2347,18 +2548,26 @@ app.post('/api/generateNewAPIKey', function (req, res) { // Streaming API calls -app.get('/api/video/:id', function(req , res){ +app.get('/api/video/:id', optionalJwt, function(req , res){ var head; let optionalParams = url_api.parse(req.url,true).query; let id = decodeURIComponent(req.params.id); - let path = videoFolderPath + id + '.mp4'; - if (optionalParams['subName']) { + let file_path = videoFolderPath + id + '.mp4'; + if (req.isAuthenticated()) { + let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + if (optionalParams['subName']) { + const isPlaylist = optionalParams['subPlaylist']; + file_path = path.join(usersFileFolder, req.user.uid, 'subscriptions', (isPlaylist === 'true' ? 'playlists/' : 'channels/'),optionalParams['subName'], id + '.mp4') + } else { + file_path = path.join(usersFileFolder, req.user.uid, 'video', id + '.mp4'); + } + } else if (optionalParams['subName']) { let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); const isPlaylist = optionalParams['subPlaylist']; basePath += (isPlaylist === 'true' ? 'playlists/' : 'channels/'); - path = basePath + optionalParams['subName'] + '/' + id + '.mp4'; + file_path = basePath + optionalParams['subName'] + '/' + id + '.mp4'; } - const stat = fs.statSync(path) + const stat = fs.statSync(file_path) const fileSize = stat.size const range = req.headers.range if (range) { @@ -2368,12 +2577,12 @@ app.get('/api/video/:id', function(req , res){ ? parseInt(parts[1], 10) : fileSize-1 const chunksize = (end-start)+1 - const file = fs.createReadStream(path, {start, end}) - if (descriptors[id]) descriptors[id].push(file); - else descriptors[id] = [file]; + const file = fs.createReadStream(file_path, {start, end}) + if (config_api.descriptors[id]) config_api.descriptors[id].push(file); + else config_api.descriptors[id] = [file]; file.on('close', function() { - let index = descriptors[id].indexOf(file); - descriptors[id].splice(index, 1); + let index = config_api.descriptors[id].indexOf(file); + config_api.descriptors[id].splice(index, 1); logger.debug('Successfully closed stream and removed file reference.'); }); head = { @@ -2390,16 +2599,20 @@ app.get('/api/video/:id', function(req , res){ 'Content-Type': 'video/mp4', } res.writeHead(200, head) - fs.createReadStream(path).pipe(res) + fs.createReadStream(file_path).pipe(res) } }); -app.get('/api/audio/:id', function(req , res){ +app.get('/api/audio/:id', optionalJwt, function(req , res){ var head; let id = decodeURIComponent(req.params.id); - let path = "audio/" + id + '.mp3'; - path = path.replace(/\"/g, '\''); - const stat = fs.statSync(path) + let file_path = "audio/" + id + '.mp3'; + if (req.isAuthenticated()) { + let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + file_path = path.join(usersFileFolder, req.user.name, 'audio', id + '.mp3'); + } + file_path = file_path.replace(/\"/g, '\''); + const stat = fs.statSync(file_path) const fileSize = stat.size const range = req.headers.range if (range) { @@ -2409,12 +2622,12 @@ app.get('/api/audio/:id', function(req , res){ ? parseInt(parts[1], 10) : fileSize-1 const chunksize = (end-start)+1 - const file = fs.createReadStream(path, {start, end}); - if (descriptors[id]) descriptors[id].push(file); - else descriptors[id] = [file]; + const file = fs.createReadStream(file_path, {start, end}); + if (config_api.descriptors[id]) config_api.descriptors[id].push(file); + else config_api.descriptors[id] = [file]; file.on('close', function() { - let index = descriptors[id].indexOf(file); - descriptors[id].splice(index, 1); + let index = config_api.descriptors[id].indexOf(file); + config_api.descriptors[id].splice(index, 1); logger.debug('Successfully closed stream and removed file reference.'); }); head = { @@ -2431,7 +2644,7 @@ app.get('/api/audio/:id', function(req , res){ 'Content-Type': 'audio/mp3', } res.writeHead(200, head) - fs.createReadStream(path).pipe(res) + fs.createReadStream(file_path).pipe(res) } }); @@ -2524,6 +2737,108 @@ app.get('/api/audio/:id', function(req , res){ }) }); +// user authentication + +app.post('/api/auth/register' + , auth_api.registerUser); +app.post('/api/auth/login' + , auth_api.passport.authenticate('local', {}) + , auth_api.passport.authorize('local') + , 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.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 = auth_api.adminExists(); + res.send({exists: exists}); +}); + +// user management +app.post('/api/getUsers', optionalJwt, async (req, res) => { + let users = users_db.get('users').value(); + res.send({users: users}); +}); +app.post('/api/getRoles', optionalJwt, async (req, res) => { + let roles = users_db.get('roles').value(); + res.send({roles: roles}); +}); + +app.post('/api/changeUser', optionalJwt, async (req, res) => { + let change_obj = req.body.change_object; + try { + const user_db_obj = users_db.get('users').find({uid: change_obj.uid}); + if (change_obj.name) { + user_db_obj.assign({name: change_obj.name}).write(); + } + if (change_obj.role) { + user_db_obj.assign({role: change_obj.role}).write(); + } + 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 { + let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + const user_folder = path.join(__dirname, usersFileFolder, uid); + const user_db_obj = users_db.get('users').find({uid: uid}); + if (user_db_obj.value()) { + // user exists, let's delete + deleteFolderRecursive(user_folder); + users_db.get('users').remove({uid: uid}).write(); + } + res.send({success: true}); + } 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 = 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 = auth_api.changeRolePermissions(role, permission, new_value); + + res.send({success: success}); +}); + app.use(function(req, res, next) { //if the request is not html then move along var accept = req.accepts('html', 'json', 'xml'); diff --git a/backend/appdata/default.json b/backend/appdata/default.json index a987394..4ed35ed 100644 --- a/backend/appdata/default.json +++ b/backend/appdata/default.json @@ -39,9 +39,13 @@ "subscriptions_check_interval": "300", "subscriptions_use_youtubedl_archive": true }, + "Users": { + "base_path": "users/" + }, "Advanced": { "use_default_downloading_agent": true, "custom_downloading_agent": "", + "multi_user_mode": false, "allow_advanced_download": false } } diff --git a/backend/appdata/encrypted.json b/backend/appdata/encrypted.json index 04de64a..9817beb 100644 --- a/backend/appdata/encrypted.json +++ b/backend/appdata/encrypted.json @@ -39,9 +39,13 @@ "subscriptions_check_interval": "300", "subscriptions_use_youtubedl_archive": true }, + "Users": { + "base_path": "users/" + }, "Advanced": { "use_default_downloading_agent": true, "custom_downloading_agent": "", + "multi_user_mode": false, "allow_advanced_download": false } } diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js new file mode 100644 index 0000000..7ca09fa --- /dev/null +++ b/backend/authentication/auth.js @@ -0,0 +1,530 @@ +const path = require('path'); +const config_api = require('../config'); +const consts = require('../consts'); +var subscriptions_api = require('../subscriptions') +const fs = require('fs-extra'); +var jwt = require('jsonwebtoken'); +const { uuid } = require('uuidv4'); +var bcrypt = require('bcrypt'); + + +var LocalStrategy = require('passport-local').Strategy; +var JwtStrategy = require('passport-jwt').Strategy, + ExtractJwt = require('passport-jwt').ExtractJwt; + +// other required vars +let logger = null; +var users_db = null; +let SERVER_SECRET = null; +let JWT_EXPIRATION = null; +let opts = null; +let saltRounds = null; + +exports.initialize = function(input_users_db, input_logger) { + setLogger(input_logger) + setDB(input_users_db); + + /************************* + * Authentication module + ************************/ + saltRounds = 10; + + JWT_EXPIRATION = (60 * 60); // one hour + + SERVER_SECRET = null; + if (users_db.get('jwt_secret').value()) { + SERVER_SECRET = users_db.get('jwt_secret').value(); + } else { + SERVER_SECRET = uuid(); + users_db.set('jwt_secret', SERVER_SECRET).write(); + } + + opts = {} + opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt'); + opts.secretOrKey = SERVER_SECRET; + /*opts.issuer = 'example.com'; + opts.audience = 'example.com';*/ + + exports.passport.use(new JwtStrategy(opts, function(jwt_payload, done) { + const user = users_db.get('users').find({uid: jwt_payload.user.uid}).value(); + if (user) { + return done(null, user); + } else { + return done(null, false); + // or you could create a new account + } + })); +} + +function setLogger(input_logger) { + logger = input_logger; +} + +function setDB(input_users_db) { + users_db = input_users_db; +} + +exports.passport = require('passport'); + +exports.passport.serializeUser(function(user, done) { + done(null, user); +}); + +exports.passport.deserializeUser(function(user, done) { + done(null, user); +}); + +/*************************************** + * Register user with hashed password + **************************************/ +exports.registerUser = function(req, res) { + var userid = req.body.userid; + var username = req.body.username; + var plaintextPassword = req.body.password; + + bcrypt.hash(plaintextPassword, saltRounds) + .then(function(hash) { + let new_user = { + name: username, + uid: userid, + passhash: hash, + files: { + audio: [], + video: [] + }, + playlists: { + audio: [], + video: [] + }, + subscriptions: [], + created: Date.now(), + role: userid === 'admin' ? 'admin' : 'user', + permissions: [], + permission_overrides: [] + }; + // check if user exists + if (users_db.get('users').find({uid: userid}).value()) { + // user id is taken! + logger.error('Registration failed: UID is already taken!'); + res.status(409).send('UID is already taken!'); + } else if (users_db.get('users').find({name: username}).value()) { + // user name is taken! + logger.error('Registration failed: User name is already taken!'); + res.status(409).send('User name is already taken!'); + } else { + // add to db + users_db.get('users').push(new_user).write(); + logger.verbose(`New user created: ${new_user.name}`); + res.send({ + user: new_user + }); + } + }) + .then(function(result) { + + }) + .catch(function(err) { + logger.error(err); + if( err.code == 'ER_DUP_ENTRY' ) { + res.status(409).send('UserId already taken'); + } else { + res.sendStatus(409); + } + }); +} + +/*************************************** + * Login methods + **************************************/ + +/************************************************* + * This gets called when passport.authenticate() + * gets called. + * + * This checks that the credentials are valid. + * If so, passes the user info to the next middleware. + ************************************************/ + + +exports.passport.use(new LocalStrategy({ + usernameField: 'userid', + passwordField: 'password'}, + function(username, password, done) { + const user = users_db.get('users').find({name: username}).value(); + if (!user) { console.log('user not found'); return done(null, false); } + if (user) { + return done(null, bcrypt.compareSync(password, user.passhash) ? user : false); + } + } +)); + +/*passport.use(new BasicStrategy( + function(userid, plainTextPassword, done) { + const user = users_db.get('users').find({name: userid}).value(); + if (user) { + var hashedPwd = user.passhash; + return bcrypt.compare(plainTextPassword, hashedPwd); + } else { + return false; + } + } +)); +*/ + +/************************************************************* + * This is a wrapper for auth.passport.authenticate(). + * We use this to change WWW-Authenticate header so + * the browser doesn't pop-up challenge dialog box by default. + * Browser's will pop-up up dialog when status is 401 and + * "WWW-Authenticate:Basic..." + *************************************************************/ +/* +exports.authenticateViaPassport = function(req, res, next) { + exports.passport.authenticate('basic',{session:false}, + function(err, user, info) { + if(!user){ + res.set('WWW-Authenticate', 'x'+info); // change to xBasic + res.status(401).send('Invalid Authentication'); + } else { + req.user = user; + next(); + } + } + )(req, res, next); +}; +*/ + +/********************************** + * Generating/Signing a JWT token + * And attaches the user info into + * the payload to be sent on every + * request. + *********************************/ +exports.generateJWT = function(req, res, next) { + var payload = { + exp: Math.floor(Date.now() / 1000) + JWT_EXPIRATION + , user: req.user + }; + req.token = jwt.sign(payload, SERVER_SECRET); + next(); +} + +exports.returnAuthResponse = function(req, res) { + res.status(200).json({ + user: req.user, + token: req.token, + permissions: exports.userPermissions(req.user.uid), + available_permissions: consts['AVAILABLE_PERMISSIONS'] + }); +} + +/*************************************** + * Authorization: middleware that checks the + * JWT token for validity before allowing + * the user to access anything. + * + * It also passes the user object to the next + * middleware through res.locals + **************************************/ +exports.ensureAuthenticatedElseError = function(req, res, next) { + var token = getToken(req.query); + if( token ) { + try { + var payload = jwt.verify(token, SERVER_SECRET); + // console.log('payload: ' + JSON.stringify(payload)); + // check if user still exists in database if you'd like + res.locals.user = payload.user; + next(); + } catch(err) { + res.status(401).send('Invalid Authentication'); + } + } else { + res.status(401).send('Missing Authorization header'); + } +} + +// change password +exports.changeUserPassword = async function(user_uid, new_pass) { + return new Promise(resolve => { + bcrypt.hash(new_pass, saltRounds) + .then(function(hash) { + users_db.get('users').find({uid: user_uid}).assign({passhash: hash}).write(); + resolve(true); + }).catch(err => { + resolve(false); + }); + }); +} + +// change user permissions +exports.changeUserPermissions = function(user_uid, permission, new_value) { + try { + const user_db_obj = users_db.get('users').find({uid: user_uid}); + user_db_obj.get('permissions').pull(permission).write(); + user_db_obj.get('permission_overrides').pull(permission).write(); + if (new_value === 'yes') { + user_db_obj.get('permissions').push(permission).write(); + user_db_obj.get('permission_overrides').push(permission).write(); + } else if (new_value === 'no') { + user_db_obj.get('permission_overrides').push(permission).write(); + } + return true; + } catch (err) { + logger.error(err); + return false; + } +} + +// change role permissions +exports.changeRolePermissions = function(role, permission, new_value) { + try { + const role_db_obj = users_db.get('roles').get(role); + role_db_obj.get('permissions').pull(permission).write(); + if (new_value === 'yes') { + role_db_obj.get('permissions').push(permission).write(); + } + return true; + } catch (err) { + logger.error(err); + return false; + } +} + +exports.adminExists = function() { + return !!users_db.get('users').find({uid: 'admin'}).value(); +} + +// video stuff + +exports.getUserVideos = function(user_uid, type) { + const user = users_db.get('users').find({uid: user_uid}).value(); + return user['files'][type]; +} + +exports.getUserVideo = function(user_uid, file_uid, type, requireSharing = false) { + if (!type) { + file = users_db.get('users').find({uid: user_uid}).get(`files.audio`).find({uid: file_uid}).value(); + if (!file) { + file = users_db.get('users').find({uid: user_uid}).get(`files.video`).find({uid: file_uid}).value(); + if (file) type = 'video'; + } else { + type = 'audio'; + } + } + + if (!file && type) file = users_db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value(); + + // prevent unauthorized users from accessing the file info + if (requireSharing && !file['sharingEnabled']) file = null; + + return file; +} + +exports.addPlaylist = function(user_uid, new_playlist, type) { + users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).push(new_playlist).write(); + return true; +} + +exports.updatePlaylist = function(user_uid, playlistID, new_filenames, type) { + users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).assign({fileNames: new_filenames}); + return true; +} + +exports.removePlaylist = function(user_uid, playlistID, type) { + users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).remove({id: playlistID}).write(); + return true; +} + +exports.getUserPlaylists = function(user_uid, type) { + const user = users_db.get('users').find({uid: user_uid}).value(); + return user['playlists'][type]; +} + +exports.getUserPlaylist = function(user_uid, playlistID, type) { + let playlist = null; + if (!type) { + playlist = users_db.get('users').find({uid: user_uid}).get(`playlists.audio`).find({id: playlistID}).value(); + if (!playlist) { + playlist = users_db.get('users').find({uid: user_uid}).get(`playlists.video`).find({id: playlistID}).value(); + if (playlist) type = 'video'; + } else { + type = 'audio'; + } + } + if (!playlist) playlist = users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).value(); + return playlist; +} + +exports.registerUserFile = function(user_uid, file_object, type) { + users_db.get('users').find({uid: user_uid}).get(`files.${type}`) + .remove({ + path: file_object['path'] + }).write(); + + users_db.get('users').find({uid: user_uid}).get(`files.${type}`) + .push(file_object) + .write(); +} + +exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = false) { + let success = false; + const file_obj = users_db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value(); + if (file_obj) { + const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); + const ext = type === 'audio' ? '.mp3' : '.mp4'; + + // close descriptors + if (config_api.descriptors[file_obj.id]) { + try { + for (let i = 0; i < config_api.descriptors[file_obj.id].length; i++) { + config_api.descriptors[file_obj.id][i].destroy(); + } + } catch(e) { + + } + } + + const full_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext); + users_db.get('users').find({uid: user_uid}).get(`files.${type}`) + .remove({ + uid: file_uid + }).write(); + if (fs.existsSync(full_path)) { + // remove json and file + const json_path = path.join(usersFileFolder, user_uid, type, file_obj.id + '.info.json'); + const alternate_json_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext + '.info.json'); + let youtube_id = null; + if (fs.existsSync(json_path)) { + youtube_id = fs.readJSONSync(json_path).id; + fs.unlinkSync(json_path); + } else if (fs.existsSync(alternate_json_path)) { + youtube_id = fs.readJSONSync(alternate_json_path).id; + fs.unlinkSync(alternate_json_path); + } + + fs.unlinkSync(full_path); + + // do archive stuff + + let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive'); + if (useYoutubeDLArchive) { + const archive_path = path.join(usersFileFolder, user_uid, 'archives', `archive_${type}.txt`); + + // use subscriptions API to remove video from the archive file, and write it to the blacklist + if (fs.existsSync(archive_path)) { + const line = youtube_id ? subscriptions_api.removeIDFromArchive(archive_path, youtube_id) : null; + if (blacklistMode && line) { + let blacklistPath = path.join(usersFileFolder, user_uid, 'archives', `blacklist_${type}.txt`); + // adds newline to the beginning of the line + line = '\n' + line; + fs.appendFileSync(blacklistPath, line); + } + } else { + logger.info('Could not find archive file for audio files. Creating...'); + fs.closeSync(fs.openSync(archive_path, 'w')); + } + } + } + success = true; + } else { + success = false; + console.log('file does not exist!'); + } + + return success; +} + +exports.changeSharingMode = function(user_uid, file_uid, type, is_playlist, enabled) { + let success = false; + const user_db_obj = users_db.get('users').find({uid: user_uid}); + if (user_db_obj.value()) { + const file_db_obj = is_playlist ? user_db_obj.get(`playlists.${type}`).find({uid: file_uid}) : user_db_obj.get(`files.${type}`).find({uid: file_uid}); + if (file_db_obj.value()) { + success = true; + file_db_obj.assign({sharingEnabled: enabled}).write(); + } + } + + return success; +} + +exports.userHasPermission = function(user_uid, permission) { + const user_obj = users_db.get('users').find({uid: user_uid}).value(); + const role = user_obj['role']; + if (!role) { + // role doesn't exist + logger.error('Invalid role ' + role); + return false; + } + const role_permissions = (users_db.get('roles').value())['permissions']; + + const user_has_explicit_permission = user_obj['permissions'].includes(permission); + const permission_in_overrides = user_obj['permission_overrides'].includes(permission); + + // check if user has a negative/positive override + if (user_has_explicit_permission && permission_in_overrides) { + // positive override + return true; + } else if (!user_has_explicit_permission && permission_in_overrides) { + // negative override + return false; + } + + // no overrides, let's check if the role has the permission + if (role_permissions.includes(permission)) { + return true; + } else { + logger.verbose(`User ${user_uid} failed to get permission ${permission}`); + return false; + } +} + +exports.userPermissions = function(user_uid) { + let user_permissions = []; + const user_obj = users_db.get('users').find({uid: user_uid}).value(); + const role = user_obj['role']; + if (!role) { + // role doesn't exist + logger.error('Invalid role ' + role); + return null; + } + const role_permissions = users_db.get('roles').get(role).get('permissions').value() + + for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) { + let permission = consts['AVAILABLE_PERMISSIONS'][i]; + + const user_has_explicit_permission = user_obj['permissions'].includes(permission); + const permission_in_overrides = user_obj['permission_overrides'].includes(permission); + + // check if user has a negative/positive override + if (user_has_explicit_permission && permission_in_overrides) { + // positive override + user_permissions.push(permission); + } else if (!user_has_explicit_permission && permission_in_overrides) { + // negative override + continue; + } + + // no overrides, let's check if the role has the permission + if (role_permissions.includes(permission)) { + user_permissions.push(permission); + } else { + continue; + } + } + + return user_permissions; +} + +function getToken(queryParams) { + if (queryParams && queryParams.jwt) { + var parted = queryParams.jwt.split(' '); + if (parted.length === 2) { + return parted[1]; + } else { + return null; + } + } else { + return null; + } +}; \ No newline at end of file diff --git a/backend/config.js b/backend/config.js index a38a703..19ec0ae 100644 --- a/backend/config.js +++ b/backend/config.js @@ -134,7 +134,8 @@ module.exports = { setConfigFile: setConfigFile, configExistsCheck: configExistsCheck, CONFIG_ITEMS: CONFIG_ITEMS, - setLogger: setLogger + setLogger: setLogger, + descriptors: {} } DEFAULT_CONFIG = { @@ -178,9 +179,13 @@ DEFAULT_CONFIG = { "subscriptions_check_interval": "300", "subscriptions_use_youtubedl_archive": true }, + "Users": { + "base_path": "users/" + }, "Advanced": { "use_default_downloading_agent": true, "custom_downloading_agent": "", + "multi_user_mode": false, "allow_advanced_download": false } } diff --git a/backend/consts.js b/backend/consts.js index c94aba4..cb8dc0a 100644 --- a/backend/consts.js +++ b/backend/consts.js @@ -117,6 +117,12 @@ let CONFIG_ITEMS = { 'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_use_youtubedl_archive' }, + // Users + 'ytdl_users_base_path': { + 'key': 'ytdl_users_base_path', + 'path': 'YoutubeDLMaterial.Users.base_path' + }, + // Advanced 'ytdl_use_default_downloading_agent': { 'key': 'ytdl_use_default_downloading_agent', @@ -126,13 +132,27 @@ let CONFIG_ITEMS = { 'key': 'ytdl_custom_downloading_agent', 'path': 'YoutubeDLMaterial.Advanced.custom_downloading_agent' }, + 'ytdl_multi_user_mode': { + 'key': 'ytdl_multi_user_mode', + 'path': 'YoutubeDLMaterial.Advanced.multi_user_mode' + }, 'ytdl_allow_advanced_download': { 'key': 'ytdl_allow_advanced_download', 'path': 'YoutubeDLMaterial.Advanced.allow_advanced_download' }, }; +AVAILABLE_PERMISSIONS = [ + 'filemanager', + 'settings', + 'subscriptions', + 'sharing', + 'advanced_download', + 'downloads_manager' +]; + module.exports = { CONFIG_ITEMS: CONFIG_ITEMS, + AVAILABLE_PERMISSIONS: AVAILABLE_PERMISSIONS, CURRENT_VERSION: 'v3.6' } \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 764f43d..78a2c3b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,17 +30,23 @@ "dependencies": { "archiver": "^3.1.1", "async": "^3.1.0", + "bcrypt": "^4.0.1", "compression": "^1.7.4", "config": "^3.2.3", "exe": "^1.0.2", "express": "^4.17.1", "fs-extra": "^9.0.0", + "jsonwebtoken": "^8.5.1", "lowdb": "^1.0.0", "md5": "^2.2.1", "merge-files": "^0.1.2", "node-fetch": "^2.6.0", "node-id3": "^0.1.14", "nodemon": "^2.0.2", + "passport": "^0.4.1", + "passport-http": "^0.3.0", + "passport-jwt": "^4.0.0", + "passport-local": "^1.0.0", "progress": "^2.0.3", "shortid": "^2.2.15", "unzipper": "^0.10.10", diff --git a/backend/subscriptions.js b/backend/subscriptions.js index e542714..cccd224 100644 --- a/backend/subscriptions.js +++ b/backend/subscriptions.js @@ -11,15 +11,16 @@ const debugMode = process.env.YTDL_MODE === 'debug'; var logger = null; var db = null; -function setDB(input_db) { db = input_db; } +var users_db = null; +function setDB(input_db, input_users_db) { db = input_db; users_db = input_users_db } function setLogger(input_logger) { logger = input_logger; } -function initialize(input_db, input_logger) { - setDB(input_db); +function initialize(input_db, input_users_db, input_logger) { + setDB(input_db, input_users_db); setLogger(input_logger); } -async function subscribe(sub) { +async function subscribe(sub, user_uid = null) { const result_obj = { success: false, error: '' @@ -28,7 +29,14 @@ async function subscribe(sub) { // sub should just have url and name. here we will get isPlaylist and path sub.isPlaylist = sub.url.includes('playlist'); - if (db.get('subscriptions').find({url: sub.url}).value()) { + let url_exists = false; + + if (user_uid) + url_exists = !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({url: sub.url}).value() + else + url_exists = !!db.get('subscriptions').find({url: sub.url}).value(); + + if (url_exists) { logger.info('Sub already exists'); result_obj.error = 'Subcription with URL ' + sub.url + ' already exists!'; resolve(result_obj); @@ -36,19 +44,27 @@ async function subscribe(sub) { } // add sub to db - db.get('subscriptions').push(sub).write(); + if (user_uid) + users_db.get('users').find({uid: user_uid}).get('subscriptions').push(sub).write(); + else + db.get('subscriptions').push(sub).write(); let success = await getSubscriptionInfo(sub); result_obj.success = success; result_obj.sub = sub; - getVideosForSub(sub); + getVideosForSub(sub, user_uid); resolve(result_obj); }); } -async function getSubscriptionInfo(sub) { - const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); +async function getSubscriptionInfo(sub, user_uid = null) { + let basePath = null; + if (user_uid) + basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); + else + basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + return new Promise(resolve => { // get videos let downloadConfig = ['--dump-json', '--playlist-end', '1'] @@ -74,16 +90,19 @@ async function getSubscriptionInfo(sub) { if (!output_json) { continue; } - if (!sub.name) { sub.name = sub.isPlaylist ? output_json.playlist_title : output_json.uploader; // if it's now valid, update if (sub.name) { - db.get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write(); + if (user_uid) + users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write(); + else + db.get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write(); } } - if (!sub.archive) { + const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive'); + if (useArchive && !sub.archive) { // must create the archive const archive_dir = path.join(__dirname, basePath, 'archives', sub.name); const archive_path = path.join(archive_dir, 'archive.txt'); @@ -94,7 +113,10 @@ async function getSubscriptionInfo(sub) { // updates subscription sub.archive = archive_dir; - db.get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write(); + if (user_uid) + users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write(); + else + db.get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write(); } // TODO: get even more info @@ -107,13 +129,20 @@ async function getSubscriptionInfo(sub) { }); } -async function unsubscribe(sub, deleteMode) { +async function unsubscribe(sub, deleteMode, user_uid = null) { return new Promise(async resolve => { - const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + let basePath = null; + if (user_uid) + basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); + else + basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); let result_obj = { success: false, error: '' }; let id = sub.id; - db.get('subscriptions').remove({id: id}).write(); + if (user_uid) + users_db.get('users').find({uid: user_uid}).get('subscriptions').remove({id: id}).write(); + else + db.get('subscriptions').remove({id: id}).write(); const appendedBasePath = getAppendedBasePath(sub, basePath); if (deleteMode && fs.existsSync(appendedBasePath)) { @@ -131,8 +160,12 @@ async function unsubscribe(sub, deleteMode) { } -async function deleteSubscriptionFile(sub, file, deleteForever) { - const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); +async function deleteSubscriptionFile(sub, file, deleteForever, user_uid = null) { + let basePath = null; + if (user_uid) + basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); + else + basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive'); const appendedBasePath = getAppendedBasePath(sub, basePath); const name = file; @@ -180,14 +213,27 @@ async function deleteSubscriptionFile(sub, file, deleteForever) { }); } -async function getVideosForSub(sub) { +async function getVideosForSub(sub, user_uid = null) { return new Promise(resolve => { - if (!subExists(sub.id)) { + if (!subExists(sub.id, user_uid)) { resolve(false); return; } - const sub_db = db.get('subscriptions').find({id: sub.id}); - const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + + // get sub_db + let sub_db = null; + if (user_uid) + sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}); + else + sub_db = db.get('subscriptions').find({id: sub.id}); + + // get basePath + let basePath = null; + if (user_uid) + basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); + else + basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive'); let appendedBasePath = null @@ -262,23 +308,32 @@ async function getVideosForSub(sub) { }); } -function getAllSubscriptions() { - const subscriptions = db.get('subscriptions').value(); - return subscriptions; +function getAllSubscriptions(user_uid = null) { + if (user_uid) + return users_db.get('users').find({uid: user_uid}).get('subscriptions').value(); + else + return db.get('subscriptions').value(); } -function getSubscription(subID) { - return db.get('subscriptions').find({id: subID}).value(); +function getSubscription(subID, user_uid = null) { + if (user_uid) + return users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value(); + else + return db.get('subscriptions').find({id: subID}).value(); } -function subExists(subID) { - return !!db.get('subscriptions').find({id: subID}).value(); +function subExists(subID, user_uid = null) { + if (user_uid) + return !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value(); + else + return !!db.get('subscriptions').find({id: subID}).value(); } // helper functions function getAppendedBasePath(sub, base_path) { - return base_path + (sub.isPlaylist ? 'playlists/' : 'channels/') + sub.name; + + return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name); } // https://stackoverflow.com/a/32197381/8088021 diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index e1b3dcd..5a18750 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -4,14 +4,18 @@ import { MainComponent } from './main/main.component'; import { PlayerComponent } from './player/player.component'; import { SubscriptionsComponent } from './subscriptions/subscriptions.component'; import { SubscriptionComponent } from './subscription/subscription/subscription.component'; +import { PostsService } from './posts.services'; +import { LoginComponent } from './components/login/login.component'; import { DownloadsComponent } from './components/downloads/downloads.component'; + const routes: Routes = [ - { path: 'home', component: MainComponent }, - { path: 'player', component: PlayerComponent}, - { path: 'subscriptions', component: SubscriptionsComponent }, - { path: 'subscription', component: SubscriptionComponent }, + { path: 'home', component: MainComponent, canActivate: [PostsService] }, + { path: 'player', component: PlayerComponent, canActivate: [PostsService]}, + { path: 'subscriptions', component: SubscriptionsComponent, canActivate: [PostsService] }, + { path: 'subscription', component: SubscriptionComponent, canActivate: [PostsService] }, + { path: 'login', component: LoginComponent }, { path: 'downloads', component: DownloadsComponent }, - { path: '', redirectTo: '/home', pathMatch: 'full' }, + { path: '', redirectTo: '/home', pathMatch: 'full' } ]; @NgModule({ diff --git a/src/app/app.component.html b/src/app/app.component.html index 0e03fe9..8f58860 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -14,12 +14,16 @@