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 @@
+ - @@ -37,8 +41,8 @@ Home - Subscriptions - Downloads + Subscriptions + Downloads diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 52441b5..3fa9207 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -23,6 +23,8 @@ import { THEMES_CONFIG } from '../themes'; import { SettingsComponent } from './settings/settings.component'; import { CheckOrSetPinDialogComponent } from './dialogs/check-or-set-pin-dialog/check-or-set-pin-dialog.component'; import { AboutDialogComponent } from './dialogs/about-dialog/about-dialog.component'; +import { UserProfileDialogComponent } from './dialogs/user-profile-dialog/user-profile-dialog.component'; +import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dialog/set-default-admin-dialog.component'; @Component({ selector: 'app-root', @@ -61,8 +63,7 @@ export class AppComponent implements OnInit { } }); - this.loadConfig(); - this.postsService.settings_changed.subscribe(changed => { + this.postsService.config_reloaded.subscribe(changed => { if (changed) { this.loadConfig(); } @@ -76,22 +77,17 @@ export class AppComponent implements OnInit { loadConfig() { // loading config - this.postsService.loadNavItems().subscribe(res => { // loads settings - const result = !this.postsService.debugMode ? res['config_file'] : res; - this.topBarTitle = result['YoutubeDLMaterial']['Extra']['title_top']; - this.settingsPinRequired = result['YoutubeDLMaterial']['Extra']['settings_pin_required']; - const themingExists = result['YoutubeDLMaterial']['Themes']; - this.defaultTheme = themingExists ? result['YoutubeDLMaterial']['Themes']['default_theme'] : 'default'; - this.allowThemeChange = themingExists ? result['YoutubeDLMaterial']['Themes']['allow_theme_change'] : true; - this.allowSubscriptions = result['YoutubeDLMaterial']['Subscriptions']['allow_subscriptions']; + this.topBarTitle = this.postsService.config['Extra']['title_top']; + this.settingsPinRequired = this.postsService.config['Extra']['settings_pin_required']; + const themingExists = this.postsService.config['Themes']; + this.defaultTheme = themingExists ? this.postsService.config['Themes']['default_theme'] : 'default'; + this.allowThemeChange = themingExists ? this.postsService.config['Themes']['allow_theme_change'] : true; + this.allowSubscriptions = this.postsService.config['Subscriptions']['allow_subscriptions']; - // sets theme to config default if it doesn't exist - if (!localStorage.getItem('theme')) { - this.setTheme(themingExists ? this.defaultTheme : 'default'); - } - }, error => { - console.log(error); - }); + // sets theme to config default if it doesn't exist + if (!localStorage.getItem('theme')) { + this.setTheme(themingExists ? this.defaultTheme : 'default'); + } } // theme stuff @@ -153,6 +149,18 @@ onSetTheme(theme, old_theme) { } else { // } + this.postsService.open_create_default_admin_dialog.subscribe(open => { + if (open) { + const dialogRef = this.dialog.open(SetDefaultAdminDialogComponent); + dialogRef.afterClosed().subscribe(success => { + if (success) { + if (this.router.url !== '/login') { this.router.navigate(['/login']); } + } else { + console.error('Failed to create default admin account. See logs for details.'); + } + }); + } + }); } @@ -196,5 +204,11 @@ onSetTheme(theme, old_theme) { }); } + openProfileDialog() { + const dialogRef = this.dialog.open(UserProfileDialogComponent, { + width: '60vw' + }); + } + } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index beff3cc..96b91b0 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -25,6 +25,9 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTabsModule } from '@angular/material/tabs'; +import {MatPaginatorModule} from '@angular/material/paginator'; +import {MatSortModule} from '@angular/material/sort'; +import {MatTableModule} from '@angular/material/table'; import {DragDropModule} from '@angular/cdk/drag-drop'; import {ClipboardModule} from '@angular/cdk/clipboard'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; @@ -57,7 +60,14 @@ import { ArgModifierDialogComponent, HighlightPipe } from './dialogs/arg-modifie import { UpdaterComponent } from './updater/updater.component'; import { UpdateProgressDialogComponent } from './dialogs/update-progress-dialog/update-progress-dialog.component'; import { ShareMediaDialogComponent } from './dialogs/share-media-dialog/share-media-dialog.component'; +import { LoginComponent } from './components/login/login.component'; import { DownloadsComponent } from './components/downloads/downloads.component'; +import { UserProfileDialogComponent } from './dialogs/user-profile-dialog/user-profile-dialog.component'; +import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dialog/set-default-admin-dialog.component'; +import { ModifyUsersComponent } from './components/modify-users/modify-users.component'; +import { AddUserDialogComponent } from './dialogs/add-user-dialog/add-user-dialog.component'; +import { ManageUserComponent } from './components/manage-user/manage-user.component'; +import { ManageRoleComponent } from './components/manage-role/manage-role.component'; registerLocaleData(es, 'es'); export function isVisible({ event, element, scrollContainer, offset }: IsVisibleProps) { @@ -87,7 +97,14 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible UpdaterComponent, UpdateProgressDialogComponent, ShareMediaDialogComponent, - DownloadsComponent + LoginComponent, + DownloadsComponent, + UserProfileDialogComponent, + SetDefaultAdminDialogComponent, + ModifyUsersComponent, + AddUserDialogComponent, + ManageUserComponent, + ManageRoleComponent ], imports: [ CommonModule, @@ -121,6 +138,9 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible MatAutocompleteModule, MatTabsModule, MatTooltipModule, + MatPaginatorModule, + MatSortModule, + MatTableModule, DragDropModule, ClipboardModule, VgCoreModule, diff --git a/src/app/components/login/login.component.html b/src/app/components/login/login.component.html new file mode 100644 index 0000000..7bc4ac7 --- /dev/null +++ b/src/app/components/login/login.component.html @@ -0,0 +1,39 @@ + \ No newline at end of file diff --git a/src/app/components/login/login.component.scss b/src/app/components/login/login.component.scss new file mode 100644 index 0000000..fe190de --- /dev/null +++ b/src/app/components/login/login.component.scss @@ -0,0 +1,6 @@ +.login-card { + max-width: 600px; + width: 80%; + margin: 0 auto; + margin-top: 20px; +} \ No newline at end of file diff --git a/src/app/components/login/login.component.spec.ts b/src/app/components/login/login.component.spec.ts new file mode 100644 index 0000000..d6d85a8 --- /dev/null +++ b/src/app/components/login/login.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginComponent } from './login.component'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ LoginComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/login/login.component.ts b/src/app/components/login/login.component.ts new file mode 100644 index 0000000..0665ee0 --- /dev/null +++ b/src/app/components/login/login.component.ts @@ -0,0 +1,101 @@ +import { Component, OnInit } from '@angular/core'; +import { PostsService } from 'app/posts.services'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'] +}) +export class LoginComponent implements OnInit { + + selectedTabIndex = 0; + + // login + loginUsernameInput = ''; + loginPasswordInput = ''; + loggingIn = false; + + // registration + registrationEnabled = true; + registrationUsernameInput = ''; + registrationPasswordInput = ''; + registrationPasswordConfirmationInput = ''; + registering = false; + + constructor(private postsService: PostsService, private snackBar: MatSnackBar, private router: Router) { } + + ngOnInit(): void { + if (this.postsService.isLoggedIn) { + this.router.navigate(['/home']); + } + this.postsService.service_initialized.subscribe(init => { + if (init) { + if (!this.postsService.config['Advanced']['multi_user_mode']) { + this.router.navigate(['/home']); + } + } + }); + } + + login() { + if (this.loginPasswordInput === '') { + return; + } + this.loggingIn = true; + this.postsService.login(this.loginUsernameInput, this.loginPasswordInput).subscribe(res => { + this.loggingIn = false; + }, err => { + this.loggingIn = false; + }); + } + + register() { + if (!this.registrationUsernameInput || this.registrationUsernameInput === '') { + this.openSnackBar('User name is required!'); + return; + } + + if (!this.registrationPasswordInput || this.registrationPasswordInput === '') { + this.openSnackBar('Password is required!'); + return; + } + + if (!this.registrationPasswordConfirmationInput || this.registrationPasswordConfirmationInput === '') { + this.openSnackBar('Password confirmation is required!'); + return; + } + + if (this.registrationPasswordInput !== this.registrationPasswordConfirmationInput) { + this.openSnackBar('Password confirmation is incorrect!'); + return; + } + + this.registering = true; + this.postsService.register(this.registrationUsernameInput, this.registrationPasswordInput).subscribe(res => { + this.registering = false; + if (res && res['user']) { + this.openSnackBar(`User ${res['user']['name']} successfully registered.`); + this.loginUsernameInput = res['user']['name']; + this.selectedTabIndex = 0; + } else { + + } + }, err => { + this.registering = false; + if (err && err.error && typeof err.error === 'string') { + this.openSnackBar(err.error); + } else { + console.log(err); + } + }); + } + + public openSnackBar(message: string, action: string = '') { + this.snackBar.open(message, action, { + duration: 2000, + }); + } + +} diff --git a/src/app/components/manage-role/manage-role.component.html b/src/app/components/manage-role/manage-role.component.html new file mode 100644 index 0000000..b3e8de9 --- /dev/null +++ b/src/app/components/manage-role/manage-role.component.html @@ -0,0 +1,19 @@ +

Manage role - {{role.name}}

+ + + + +

{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}

+ + + Yes + No + + +
+
+
+ + + + \ No newline at end of file diff --git a/src/app/components/manage-role/manage-role.component.scss b/src/app/components/manage-role/manage-role.component.scss new file mode 100644 index 0000000..167abd6 --- /dev/null +++ b/src/app/components/manage-role/manage-role.component.scss @@ -0,0 +1,4 @@ +.mat-radio-button { + margin-right: 10px; + margin-top: 5px; +} \ No newline at end of file diff --git a/src/app/components/manage-role/manage-role.component.spec.ts b/src/app/components/manage-role/manage-role.component.spec.ts new file mode 100644 index 0000000..2e9579e --- /dev/null +++ b/src/app/components/manage-role/manage-role.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ManageRoleComponent } from './manage-role.component'; + +describe('ManageRoleComponent', () => { + let component: ManageRoleComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ManageRoleComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ManageRoleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/manage-role/manage-role.component.ts b/src/app/components/manage-role/manage-role.component.ts new file mode 100644 index 0000000..4e05e29 --- /dev/null +++ b/src/app/components/manage-role/manage-role.component.ts @@ -0,0 +1,61 @@ +import { Component, OnInit, Inject } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { PostsService } from 'app/posts.services'; + +@Component({ + selector: 'app-manage-role', + templateUrl: './manage-role.component.html', + styleUrls: ['./manage-role.component.scss'] +}) +export class ManageRoleComponent implements OnInit { + + role = null; + available_permissions = null; + permissions = null; + + permissionToLabel = { + 'filemanager': 'File manager', + 'settings': 'Settings access', + 'subscriptions': 'Subscriptions', + 'sharing': 'Share files', + 'advanced_download': 'Use advanced download mode', + 'downloads_manager': 'Use downloads manager' + } + + constructor(public postsService: PostsService, private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any) { + if (this.data) { + this.role = this.data.role; + this.available_permissions = this.postsService.available_permissions; + this.parsePermissions(); + } + } + + ngOnInit(): void { + } + + parsePermissions() { + this.permissions = {}; + for (let i = 0; i < this.available_permissions.length; i++) { + const permission = this.available_permissions[i]; + if (this.role.permissions.includes(permission)) { + this.permissions[permission] = 'yes'; + } else { + this.permissions[permission] = 'no'; + } + } + } + + changeRolePermissions(change, permission) { + this.postsService.setRolePermission(this.role.name, permission, change.value).subscribe(res => { + if (res['success']) { + + } else { + this.permissions[permission] = this.permissions[permission] === 'yes' ? 'no' : 'yes'; + } + }, err => { + this.permissions[permission] = this.permissions[permission] === 'yes' ? 'no' : 'yes'; + }); + } + +} diff --git a/src/app/components/manage-user/manage-user.component.html b/src/app/components/manage-user/manage-user.component.html new file mode 100644 index 0000000..853cd72 --- /dev/null +++ b/src/app/components/manage-user/manage-user.component.html @@ -0,0 +1,31 @@ +

Manage user - {{user.name}}

+ + +

User UID: {{user.uid}}

+ +
+ + + + +
+ +
+ + +

{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}

+ + + Use default + Yes + No + + +
+
+
+
+ + + + \ No newline at end of file diff --git a/src/app/components/manage-user/manage-user.component.scss b/src/app/components/manage-user/manage-user.component.scss new file mode 100644 index 0000000..167abd6 --- /dev/null +++ b/src/app/components/manage-user/manage-user.component.scss @@ -0,0 +1,4 @@ +.mat-radio-button { + margin-right: 10px; + margin-top: 5px; +} \ No newline at end of file diff --git a/src/app/components/manage-user/manage-user.component.spec.ts b/src/app/components/manage-user/manage-user.component.spec.ts new file mode 100644 index 0000000..f8fe3a7 --- /dev/null +++ b/src/app/components/manage-user/manage-user.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ManageUserComponent } from './manage-user.component'; + +describe('ManageUserComponent', () => { + let component: ManageUserComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ManageUserComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ManageUserComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/manage-user/manage-user.component.ts b/src/app/components/manage-user/manage-user.component.ts new file mode 100644 index 0000000..61b38f4 --- /dev/null +++ b/src/app/components/manage-user/manage-user.component.ts @@ -0,0 +1,69 @@ +import { Component, OnInit, Inject } from '@angular/core'; +import { PostsService } from 'app/posts.services'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; + +@Component({ + selector: 'app-manage-user', + templateUrl: './manage-user.component.html', + styleUrls: ['./manage-user.component.scss'] +}) +export class ManageUserComponent implements OnInit { + + user = null; + newPasswordInput = ''; + available_permissions = null; + permissions = null; + + permissionToLabel = { + 'filemanager': 'File manager', + 'settings': 'Settings access', + 'subscriptions': 'Subscriptions', + 'sharing': 'Share files', + 'advanced_download': 'Use advanced download mode', + 'downloads_manager': 'Use downloads manager' + } + + settingNewPassword = false; + + constructor(public postsService: PostsService, @Inject(MAT_DIALOG_DATA) public data: any) { + if (this.data) { + this.user = this.data.user; + this.available_permissions = this.postsService.available_permissions; + this.parsePermissions(); + } + } + + ngOnInit(): void { + } + + parsePermissions() { + this.permissions = {}; + for (let i = 0; i < this.available_permissions.length; i++) { + const permission = this.available_permissions[i]; + if (this.user.permission_overrides.includes(permission)) { + if (this.user.permissions.includes(permission)) { + this.permissions[permission] = 'yes'; + } else { + this.permissions[permission] = 'no'; + } + } else { + this.permissions[permission] = 'default'; + } + } + } + + changeUserPermissions(change, permission) { + this.postsService.setUserPermission(this.user.uid, permission, change.value).subscribe(res => { + // console.log(res); + }); + } + + setNewPassword() { + this.settingNewPassword = true; + this.postsService.changeUserPassword(this.user.uid, this.newPasswordInput).subscribe(res => { + this.newPasswordInput = ''; + this.settingNewPassword = false; + }); + } + +} diff --git a/src/app/components/modify-users/modify-users.component.html b/src/app/components/modify-users/modify-users.component.html new file mode 100644 index 0000000..036ad2b --- /dev/null +++ b/src/app/components/modify-users/modify-users.component.html @@ -0,0 +1,107 @@ +
+
+
+
+
+ + + +
+ +
+ + + + + + User name + + + + + + + + + + + {{row.name}} + + + + + + + Role + + + + + + Admin + User + + + + + + {{row.role}} + + + + + + + Actions + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + +
+ +
+ +
+ + + +
\ No newline at end of file diff --git a/src/app/components/modify-users/modify-users.component.scss b/src/app/components/modify-users/modify-users.component.scss new file mode 100644 index 0000000..558267e --- /dev/null +++ b/src/app/components/modify-users/modify-users.component.scss @@ -0,0 +1,5 @@ +.edit-role { + position: relative; + top: -80px; + left: 35px; +} \ No newline at end of file diff --git a/src/app/components/modify-users/modify-users.component.spec.ts b/src/app/components/modify-users/modify-users.component.spec.ts new file mode 100644 index 0000000..e5e8ef8 --- /dev/null +++ b/src/app/components/modify-users/modify-users.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ModifyUsersComponent } from './modify-users.component'; + +describe('ModifyUsersComponent', () => { + let component: ModifyUsersComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ModifyUsersComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ModifyUsersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/modify-users/modify-users.component.ts b/src/app/components/modify-users/modify-users.component.ts new file mode 100644 index 0000000..9e54fda --- /dev/null +++ b/src/app/components/modify-users/modify-users.component.ts @@ -0,0 +1,219 @@ +import { Component, OnInit, Input, ViewChild, AfterViewInit } from '@angular/core'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { PostsService } from 'app/posts.services'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { AddUserDialogComponent } from 'app/dialogs/add-user-dialog/add-user-dialog.component'; +import { ManageUserComponent } from '../manage-user/manage-user.component'; +import { ManageRoleComponent } from '../manage-role/manage-role.component'; + +@Component({ + selector: 'app-modify-users', + templateUrl: './modify-users.component.html', + styleUrls: ['./modify-users.component.scss'] +}) +export class ModifyUsersComponent implements OnInit, AfterViewInit { + + displayedColumns = ['name', 'role', 'actions']; + dataSource = new MatTableDataSource(); + + deleteDialogContentSubstring = 'Are you sure you want delete user '; + + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + // MatPaginator Inputs + length = 100; + @Input() pageSize = 5; + pageSizeOptions: number[] = [5, 10, 25, 100]; + + // MatPaginator Output + pageEvent: PageEvent; + users: any; + editObject = null; + constructedObject = {}; + roles = null; + + + constructor(public postsService: PostsService, public snackBar: MatSnackBar, public dialog: MatDialog, + private dialogRef: MatDialogRef) { } + + ngOnInit() { + this.getArray(); + this.getRoles(); + } + + ngAfterViewInit() { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + /** + * Set the paginator and sort after the view init since this component will + * be able to query its view for the initialized paginator and sort. + */ + afterGetData() { + this.dataSource.sort = this.sort; + } + + setPageSizeOptions(setPageSizeOptionsInput: string) { + this.pageSizeOptions = setPageSizeOptionsInput.split(',').map(str => +str); + } + + applyFilter(filterValue: string) { + filterValue = filterValue.trim(); // Remove whitespace + filterValue = filterValue.toLowerCase(); // Datasource defaults to lowercase matches + this.dataSource.filter = filterValue; + } + + private getArray() { + this.postsService.getUsers().subscribe(res => { + this.users = res['users']; + this.createAndSortData(); + this.afterGetData(); + }); + } + + getRoles() { + this.postsService.getRoles().subscribe(res => { + this.roles = []; + const roles = res['roles']; + const role_names = Object.keys(roles); + for (let i = 0; i < role_names.length; i++) { + const role_name = role_names[i]; + this.roles.push({ + name: role_name, + permissions: roles[role_name]['permissions'] + }); + } + }); + } + + openAddUserDialog() { + const dialogRef = this.dialog.open(AddUserDialogComponent); + dialogRef.afterClosed().subscribe(user => { + if (user && !user.error) { + this.openSnackBar('Successfully added user ' + user.name); + this.getArray(); + } else if (user && user.error) { + this.openSnackBar('Failed to add user'); + } + }); + } + + finishEditing(user_uid) { + let has_finished = false; + if (this.constructedObject && this.constructedObject['name'] && this.constructedObject['role']) { + if (!isEmptyOrSpaces(this.constructedObject['name']) && !isEmptyOrSpaces(this.constructedObject['role'])) { + has_finished = true; + const index_of_object = this.indexOfUser(user_uid); + this.users[index_of_object] = this.constructedObject; + this.constructedObject = {}; + this.editObject = null; + this.setUser(this.users[index_of_object]); + this.createAndSortData(); + } + } + } + + enableEditMode(user_uid) { + if (this.uidInUserList(user_uid) && this.indexOfUser(user_uid) > -1) { + const users_index = this.indexOfUser(user_uid); + this.editObject = this.users[users_index]; + this.constructedObject['name'] = this.users[users_index].name; + this.constructedObject['uid'] = this.users[users_index].uid; + this.constructedObject['role'] = this.users[users_index].role; + } + } + + disableEditMode() { + this.editObject = null; + } + + // checks if user is in users array by name + uidInUserList(user_uid) { + for (let i = 0; i < this.users.length; i++) { + if (this.users[i].uid === user_uid) { + return true; + } + } + return false; + } + + // gets index of user in users array by name + indexOfUser(user_uid) { + for (let i = 0; i < this.users.length; i++) { + if (this.users[i].uid === user_uid) { + return i; + } + } + return -1; + } + + setUser(change_obj) { + this.postsService.changeUser(change_obj).subscribe(res => { + this.getArray(); + }); + } + + manageUser(user_uid) { + const index_of_object = this.indexOfUser(user_uid); + const user_obj = this.users[index_of_object]; + this.dialog.open(ManageUserComponent, { + data: { + user: user_obj + }, + width: '65vw' + }); + } + + removeUser(user_uid) { + this.postsService.deleteUser(user_uid).subscribe(res => { + this.getArray(); + }, err => { + this.getArray(); + }); + } + + createAndSortData() { + // Sorts the data by last finished + this.users.sort((a, b) => b.name > a.name); + + const filteredData = []; + for (let i = 0; i < this.users.length; i++) { + filteredData.push(JSON.parse(JSON.stringify(this.users[i]))); + } + + // Assign the data to the data source for the table to render + this.dataSource.data = filteredData; + } + + openModifyRole(role) { + const dialogRef = this.dialog.open(ManageRoleComponent, { + data: { + role: role + } + }); + + dialogRef.afterClosed().subscribe(success => { + this.getRoles(); + }); + } + + closeDialog() { + this.dialogRef.close(); + } + + public openSnackBar(message: string, action: string = '') { + this.snackBar.open(message, action, { + duration: 2000, + }); + } + +} + +function isEmptyOrSpaces(str){ + return str === null || str.match(/^ *$/) !== null; +} diff --git a/src/app/dialogs/add-user-dialog/add-user-dialog.component.html b/src/app/dialogs/add-user-dialog/add-user-dialog.component.html new file mode 100644 index 0000000..68cb91f --- /dev/null +++ b/src/app/dialogs/add-user-dialog/add-user-dialog.component.html @@ -0,0 +1,19 @@ +

Register a user

+ + +
+ + + +
+
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/src/app/dialogs/add-user-dialog/add-user-dialog.component.scss b/src/app/dialogs/add-user-dialog/add-user-dialog.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/dialogs/add-user-dialog/add-user-dialog.component.spec.ts b/src/app/dialogs/add-user-dialog/add-user-dialog.component.spec.ts new file mode 100644 index 0000000..2eee5ca --- /dev/null +++ b/src/app/dialogs/add-user-dialog/add-user-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AddUserDialogComponent } from './add-user-dialog.component'; + +describe('AddUserDialogComponent', () => { + let component: AddUserDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AddUserDialogComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AddUserDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dialogs/add-user-dialog/add-user-dialog.component.ts b/src/app/dialogs/add-user-dialog/add-user-dialog.component.ts new file mode 100644 index 0000000..383103a --- /dev/null +++ b/src/app/dialogs/add-user-dialog/add-user-dialog.component.ts @@ -0,0 +1,32 @@ +import { Component, OnInit } from '@angular/core'; +import { PostsService } from 'app/posts.services'; +import { MatDialogRef } from '@angular/material/dialog'; + +@Component({ + selector: 'app-add-user-dialog', + templateUrl: './add-user-dialog.component.html', + styleUrls: ['./add-user-dialog.component.scss'] +}) +export class AddUserDialogComponent implements OnInit { + + usernameInput = ''; + passwordInput = ''; + + constructor(private postsService: PostsService, public dialogRef: MatDialogRef) { } + + ngOnInit(): void { + } + + createUser() { + this.postsService.register(this.usernameInput, this.passwordInput).subscribe(res => { + if (res['user']) { + this.dialogRef.close(res['user']); + } else { + this.dialogRef.close({error: 'Unknown error'}); + } + }, err => { + this.dialogRef.close({error: err}); + }); + } + +} diff --git a/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.html b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.html new file mode 100644 index 0000000..4cfbda1 --- /dev/null +++ b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.html @@ -0,0 +1,19 @@ +

Create admin account

+ + +
+

No default admin account detected. This will create and set the password for an admin account with the user name as 'admin'.

+
+
+
+ + + +
+
+
+ + + +
+
\ No newline at end of file diff --git a/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.scss b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.scss new file mode 100644 index 0000000..efe2b9e --- /dev/null +++ b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.scss @@ -0,0 +1,5 @@ +.spinner-div { + position: relative; + left: 10px; + bottom: 5px; +} \ No newline at end of file diff --git a/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.spec.ts b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.spec.ts new file mode 100644 index 0000000..cc37170 --- /dev/null +++ b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SetDefaultAdminDialogComponent } from './set-default-admin-dialog.component'; + +describe('SetDefaultAdminDialogComponent', () => { + let component: SetDefaultAdminDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SetDefaultAdminDialogComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SetDefaultAdminDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.ts b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.ts new file mode 100644 index 0000000..eec524a --- /dev/null +++ b/src/app/dialogs/set-default-admin-dialog/set-default-admin-dialog.component.ts @@ -0,0 +1,33 @@ +import { Component, OnInit } from '@angular/core'; +import { PostsService } from 'app/posts.services'; +import { MatDialogRef } from '@angular/material/dialog'; + +@Component({ + selector: 'app-set-default-admin-dialog', + templateUrl: './set-default-admin-dialog.component.html', + styleUrls: ['./set-default-admin-dialog.component.scss'] +}) +export class SetDefaultAdminDialogComponent implements OnInit { + creating = false; + input = ''; + constructor(private postsService: PostsService, public dialogRef: MatDialogRef) { } + + ngOnInit(): void { + } + + create() { + this.creating = true; + this.postsService.createAdminAccount(this.input).subscribe(res => { + this.creating = false; + if (res['success']) { + this.dialogRef.close(true); + } else { + this.dialogRef.close(false); + } + }, err => { + console.log(err); + this.dialogRef.close(false); + }); + } + +} diff --git a/src/app/dialogs/share-media-dialog/share-media-dialog.component.ts b/src/app/dialogs/share-media-dialog/share-media-dialog.component.ts index 950a6cc..d6771f5 100644 --- a/src/app/dialogs/share-media-dialog/share-media-dialog.component.ts +++ b/src/app/dialogs/share-media-dialog/share-media-dialog.component.ts @@ -13,6 +13,7 @@ export class ShareMediaDialogComponent implements OnInit { type = null; uid = null; + uuid = null; share_url = null; sharing_enabled = null; is_playlist = null; @@ -24,11 +25,15 @@ export class ShareMediaDialogComponent implements OnInit { if (this.data) { this.type = this.data.type; this.uid = this.data.uid; + this.uuid = this.data.uuid; this.sharing_enabled = this.data.sharing_enabled; this.is_playlist = this.data.is_playlist; const arg = (this.is_playlist ? ';id=' : ';uid='); this.share_url = window.location.href.split(';')[0] + arg + this.uid; + if (this.uuid) { + this.share_url += ';uuid=' + this.uuid; + } } } diff --git a/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html new file mode 100644 index 0000000..c3bb84f --- /dev/null +++ b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html @@ -0,0 +1,31 @@ +

Your Profile

+ + +
+
+ Name: {{postsService.user.name}} +
+
+ UID: {{postsService.user.uid}} +
+
+ Created: {{postsService.user.created ? (postsService.user.created | date) : 'N/A'}} +
+
+
+
+ +
+
warnYou are not logged in.
+ +
+
+ + +
+
+ + +
+
+
diff --git a/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.scss b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.spec.ts b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.spec.ts new file mode 100644 index 0000000..364e28b --- /dev/null +++ b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserProfileDialogComponent } from './user-profile-dialog.component'; + +describe('UserProfileDialogComponent', () => { + let component: UserProfileDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ UserProfileDialogComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserProfileDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.ts b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.ts new file mode 100644 index 0000000..17d8c86 --- /dev/null +++ b/src/app/dialogs/user-profile-dialog/user-profile-dialog.component.ts @@ -0,0 +1,28 @@ +import { Component, OnInit } from '@angular/core'; +import { PostsService } from 'app/posts.services'; +import { Router } from '@angular/router'; +import { MatDialogRef } from '@angular/material/dialog'; + +@Component({ + selector: 'app-user-profile-dialog', + templateUrl: './user-profile-dialog.component.html', + styleUrls: ['./user-profile-dialog.component.scss'] +}) +export class UserProfileDialogComponent implements OnInit { + + constructor(public postsService: PostsService, private router: Router, public dialogRef: MatDialogRef) { } + + ngOnInit(): void { + } + + loginClicked() { + this.router.navigate(['/login']); + this.dialogRef.close(); + } + + logoutClicked() { + this.postsService.logout(); + this.dialogRef.close(); + } + +} diff --git a/src/app/main/main.component.html b/src/app/main/main.component.html index 46079fa..3d41d80 100644 --- a/src/app/main/main.component.html +++ b/src/app/main/main.component.html @@ -186,7 +186,7 @@ -
+
diff --git a/src/app/main/main.component.ts b/src/app/main/main.component.ts index 1294d29..c7b20ec 100644 --- a/src/app/main/main.component.ts +++ b/src/app/main/main.component.ts @@ -3,7 +3,6 @@ import {PostsService} from '../posts.services'; import {FileCardComponent} from '../file-card/file-card.component'; import { Observable } from 'rxjs/Observable'; import {FormControl, Validators} from '@angular/forms'; -import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { saveAs } from 'file-saver'; @@ -215,7 +214,7 @@ export class MainComponent implements OnInit { simulatedOutput = ''; - constructor(private postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar, + constructor(public postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar, private router: Router, public dialog: MatDialog, private platform: Platform, private route: ActivatedRoute) { this.audioOnly = false; } @@ -231,80 +230,81 @@ export class MainComponent implements OnInit { async loadConfig() { // loading config - this.postsService.loadNavItems().subscribe(res => { // loads settings - const result = !this.postsService.debugMode ? res['config_file'] : res; - this.fileManagerEnabled = result['YoutubeDLMaterial']['Extra']['file_manager_enabled']; - this.downloadOnlyMode = result['YoutubeDLMaterial']['Extra']['download_only_mode']; - this.allowMultiDownloadMode = result['YoutubeDLMaterial']['Extra']['allow_multi_download_mode']; - this.audioFolderPath = result['YoutubeDLMaterial']['Downloader']['path-audio']; - this.videoFolderPath = result['YoutubeDLMaterial']['Downloader']['path-video']; - this.use_youtubedl_archive = result['YoutubeDLMaterial']['Downloader']['use_youtubedl_archive']; - this.globalCustomArgs = result['YoutubeDLMaterial']['Downloader']['custom_args']; - this.youtubeSearchEnabled = result['YoutubeDLMaterial']['API'] && result['YoutubeDLMaterial']['API']['use_youtube_API'] && - result['YoutubeDLMaterial']['API']['youtube_API_key']; - this.youtubeAPIKey = this.youtubeSearchEnabled ? result['YoutubeDLMaterial']['API']['youtube_API_key'] : null; - this.allowQualitySelect = result['YoutubeDLMaterial']['Extra']['allow_quality_select']; - this.allowAdvancedDownload = result['YoutubeDLMaterial']['Advanced']['allow_advanced_download']; - this.useDefaultDownloadingAgent = result['YoutubeDLMaterial']['Advanced']['use_default_downloading_agent']; - this.customDownloadingAgent = result['YoutubeDLMaterial']['Advanced']['custom_downloading_agent']; + this.fileManagerEnabled = this.postsService.config['Extra']['file_manager_enabled']; + this.downloadOnlyMode = this.postsService.config['Extra']['download_only_mode']; + this.allowMultiDownloadMode = this.postsService.config['Extra']['allow_multi_download_mode']; + this.audioFolderPath = this.postsService.config['Downloader']['path-audio']; + this.videoFolderPath = this.postsService.config['Downloader']['path-video']; + this.use_youtubedl_archive = this.postsService.config['Downloader']['use_youtubedl_archive']; + this.globalCustomArgs = this.postsService.config['Downloader']['custom_args']; + this.youtubeSearchEnabled = this.postsService.config['API'] && this.postsService.config['API']['use_youtube_API'] && + this.postsService.config['API']['youtube_API_key']; + this.youtubeAPIKey = this.youtubeSearchEnabled ? this.postsService.config['API']['youtube_API_key'] : null; + this.allowQualitySelect = this.postsService.config['Extra']['allow_quality_select']; + this.allowAdvancedDownload = this.postsService.config['Advanced']['allow_advanced_download'] + && (!this.postsService.isLoggedIn || this.postsService.permissions.includes('advanced_download')); + this.useDefaultDownloadingAgent = this.postsService.config['Advanced']['use_default_downloading_agent']; + this.customDownloadingAgent = this.postsService.config['Advanced']['custom_downloading_agent']; - if (this.fileManagerEnabled) { - this.getMp3s(); - this.getMp4s(); + if (this.fileManagerEnabled) { + this.getMp3s(); + this.getMp4s(); + } + + if (this.youtubeSearchEnabled && this.youtubeAPIKey) { + this.youtubeSearch.initializeAPI(this.youtubeAPIKey); + this.attachToInput(); + } + + // set final cache items + if (this.allowAdvancedDownload) { + if (localStorage.getItem('customArgsEnabled') !== null) { + this.customArgsEnabled = localStorage.getItem('customArgsEnabled') === 'true'; } - if (this.youtubeSearchEnabled && this.youtubeAPIKey) { - this.youtubeSearch.initializeAPI(this.youtubeAPIKey); - this.attachToInput(); + if (localStorage.getItem('customOutputEnabled') !== null) { + this.customOutputEnabled = localStorage.getItem('customOutputEnabled') === 'true'; } - // set final cache items - if (this.allowAdvancedDownload) { - if (localStorage.getItem('customArgsEnabled') !== null) { - this.customArgsEnabled = localStorage.getItem('customArgsEnabled') === 'true'; - } - - if (localStorage.getItem('customOutputEnabled') !== null) { - this.customOutputEnabled = localStorage.getItem('customOutputEnabled') === 'true'; - } - - if (localStorage.getItem('youtubeAuthEnabled') !== null) { - this.youtubeAuthEnabled = localStorage.getItem('youtubeAuthEnabled') === 'true'; - } - - // set advanced inputs - const customArgs = localStorage.getItem('customArgs'); - const customOutput = localStorage.getItem('customOutput'); - const youtubeUsername = localStorage.getItem('youtubeUsername'); - - if (customArgs && customArgs !== 'null') { this.customArgs = customArgs }; - if (customOutput && customOutput !== 'null') { this.customOutput = customOutput }; - if (youtubeUsername && youtubeUsername !== 'null') { this.youtubeUsername = youtubeUsername }; + if (localStorage.getItem('youtubeAuthEnabled') !== null) { + this.youtubeAuthEnabled = localStorage.getItem('youtubeAuthEnabled') === 'true'; } - // get downloads routine - setInterval(() => { - if (this.current_download) { - this.getCurrentDownload(); - } - }, 500); + // set advanced inputs + const customArgs = localStorage.getItem('customArgs'); + const customOutput = localStorage.getItem('customOutput'); + const youtubeUsername = localStorage.getItem('youtubeUsername'); - return true; + if (customArgs && customArgs !== 'null') { this.customArgs = customArgs }; + if (customOutput && customOutput !== 'null') { this.customOutput = customOutput }; + if (youtubeUsername && youtubeUsername !== 'null') { this.youtubeUsername = youtubeUsername }; + } - }, error => { - console.log(error); + // get downloads routine + setInterval(() => { + if (this.current_download) { + this.getCurrentDownload(); + } + }, 500); - return false; - }); + return true; } // app initialization. ngOnInit() { - this.configLoad(); + if (this.postsService.initialized) { + this.configLoad(); + } else { + this.postsService.service_initialized.subscribe(init => { + if (init) { + this.configLoad(); + } + }); + } - this.postsService.settings_changed.subscribe(changed => { + this.postsService.config_reloaded.subscribe(changed => { if (changed) { this.loadConfig(); } @@ -1136,18 +1136,18 @@ export class MainComponent implements OnInit { } getCurrentDownload() { - this.postsService.getCurrentDownload(this.postsService.session_id, - this.current_download['ui_uid'] ? this.current_download['ui_uid'] : this.current_download['uid']).subscribe(res => { - const ui_uid = this.current_download['ui_uid'] ? this.current_download['ui_uid'] : this.current_download['uid']; + if (!this.current_download) { + return; + } + const ui_uid = this.current_download['ui_uid'] ? this.current_download['ui_uid'] : this.current_download['uid']; + this.postsService.getCurrentDownload(this.postsService.session_id, ui_uid).subscribe(res => { if (res['download']) { - console.log('got new download'); if (ui_uid === res['download']['ui_uid']) { this.current_download = res['download']; this.percentDownloaded = this.current_download.percent_complete; - console.log(this.percentDownloaded); } } else { - console.log('failed to get new download'); + // console.log('failed to get new download'); } }); } diff --git a/src/app/player/player.component.html b/src/app/player/player.component.html index 6c03c3e..fce9125 100644 --- a/src/app/player/player.component.html +++ b/src/app/player/player.component.html @@ -26,10 +26,10 @@
- +
- +
\ No newline at end of file diff --git a/src/app/player/player.component.ts b/src/app/player/player.component.ts index 83acebb..2cad9c6 100644 --- a/src/app/player/player.component.ts +++ b/src/app/player/player.component.ts @@ -39,6 +39,7 @@ export class PlayerComponent implements OnInit { uid = null; // used for non-subscription files (audio, video, playlist) subscriptionName = null; subPlaylist = null; + uuid = null; // used for sharing in multi-user mode, uuid is the user that downloaded the video is_shared = false; @@ -50,6 +51,8 @@ export class PlayerComponent implements OnInit { videoFolderPath = null; subscriptionFolderPath = null; + sharingEnabled = null; + // url-mode params url = null; name = null; @@ -73,62 +76,70 @@ export class PlayerComponent implements OnInit { this.subPlaylist = this.route.snapshot.paramMap.get('subPlaylist'); this.url = this.route.snapshot.paramMap.get('url'); this.name = this.route.snapshot.paramMap.get('name'); + this.uuid = this.route.snapshot.paramMap.get('uuid'); // loading config - this.postsService.loadNavItems().subscribe(res => { // loads settings - const result = !this.postsService.debugMode ? res['config_file'] : res; - this.baseStreamPath = this.postsService.path; - this.audioFolderPath = result['YoutubeDLMaterial']['Downloader']['path-audio']; - this.videoFolderPath = result['YoutubeDLMaterial']['Downloader']['path-video']; - this.subscriptionFolderPath = result['YoutubeDLMaterial']['Subscriptions']['subscriptions_base_path']; - this.fileNames = this.route.snapshot.paramMap.get('fileNames') ? this.route.snapshot.paramMap.get('fileNames').split('|nvr|') : null; - - if (!this.fileNames) { - this.is_shared = true; - } - - if (this.uid && !this.id) { - this.getFile(); - } else if (this.id) { - this.getPlaylistFiles(); - } - - if (this.url) { - // if a url is given, just stream the URL - this.playlist = []; - const imedia: IMedia = { - title: this.name, - label: this.name, - src: this.url, - type: 'video/mp4' + if (this.postsService.initialized) { + this.processConfig(); + } else { + this.postsService.service_initialized.subscribe(init => { // loads settings + if (init) { + this.processConfig(); } - this.playlist.push(imedia); - this.currentItem = this.playlist[0]; - this.currentIndex = 0; - this.show_player = true; - } else if (this.type === 'subscription' || this.fileNames) { - this.show_player = true; - this.parseFileNames(); - } - }); - - // this.getFileInfos(); - + }); + } } - constructor(private postsService: PostsService, private route: ActivatedRoute, private dialog: MatDialog, private router: Router, + constructor(public postsService: PostsService, private route: ActivatedRoute, private dialog: MatDialog, private router: Router, public snackBar: MatSnackBar) { } + processConfig() { + this.baseStreamPath = this.postsService.path; + this.audioFolderPath = this.postsService.config['Downloader']['path-audio']; + this.videoFolderPath = this.postsService.config['Downloader']['path-video']; + this.subscriptionFolderPath = this.postsService.config['Subscriptions']['subscriptions_base_path']; + this.fileNames = this.route.snapshot.paramMap.get('fileNames') ? this.route.snapshot.paramMap.get('fileNames').split('|nvr|') : null; + + if (!this.fileNames) { + this.is_shared = true; + } + + if (this.uid && !this.id) { + this.getFile(); + } else if (this.id) { + this.getPlaylistFiles(); + } + + if (this.url) { + // if a url is given, just stream the URL + this.playlist = []; + const imedia: IMedia = { + title: this.name, + label: this.name, + src: this.url, + type: 'video/mp4' + } + this.playlist.push(imedia); + this.currentItem = this.playlist[0]; + this.currentIndex = 0; + this.show_player = true; + } else if (this.type === 'subscription' || this.fileNames) { + this.show_player = true; + this.parseFileNames(); + } + } + getFile() { const already_has_filenames = !!this.fileNames; - this.postsService.getFile(this.uid, null).subscribe(res => { + this.postsService.getFile(this.uid, null, this.uuid).subscribe(res => { this.db_file = res['file']; if (!this.db_file) { this.openSnackBar('Failed to get file information from the server.', 'Dismiss'); return; } + this.sharingEnabled = this.db_file.sharingEnabled; if (!this.fileNames) { // means it's a shared video if (!this.id) { @@ -183,6 +194,14 @@ export class PlayerComponent implements OnInit { fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName + '&subPlaylist=' + this.subPlaylist; } + + // adds user token if in multi-user-mode + if (this.postsService.isLoggedIn) { + fullLocation += (this.subscriptionName ? '&' : '?') + `jwt=${this.postsService.token}`; + if (this.is_shared) { fullLocation += `&uuid=${this.uuid}&uid=${this.db_file.uid}&type=${this.db_file.type}`; } + } else if (this.is_shared) { + fullLocation += (this.subscriptionName ? '&' : '?') + `uuid=${this.uuid}&uid=${this.db_file.uid}&type=${this.db_file.type}`; + } // if it has a slash (meaning it's in a directory), only get the file name for the label let label = null; const decodedName = decodeURIComponent(fileName); @@ -273,7 +292,8 @@ export class PlayerComponent implements OnInit { const ext = (this.type === 'audio') ? '.mp3' : '.mp4'; const filename = this.playlist[0].title; this.downloading = true; - this.postsService.downloadFileFromServer(filename, this.type, null, null, this.subscriptionName, this.subPlaylist).subscribe(res => { + this.postsService.downloadFileFromServer(filename, this.type, null, null, this.subscriptionName, this.subPlaylist, + this.is_shared ? this.db_file['uid'] : null, this.uuid).subscribe(res => { this.downloading = false; const blob: Blob = res; saveAs(blob, filename + ext); @@ -360,7 +380,8 @@ export class PlayerComponent implements OnInit { uid: this.id ? this.id : this.uid, type: this.type, sharing_enabled: this.id ? this.db_playlist.sharingEnabled : this.db_file.sharingEnabled, - is_playlist: !!this.id + is_playlist: !!this.id, + uuid: this.postsService.isLoggedIn ? this.postsService.user.uid : null }, width: '60vw' }); diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index e881045..a4bb0dc 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -5,13 +5,15 @@ import 'rxjs/add/operator/map'; import 'rxjs/add/operator/catch'; import 'rxjs/add/observable/throw'; import { THEMES_CONFIG } from '../themes'; -import { Router } from '@angular/router'; +import { Router, CanActivate } from '@angular/router'; import { DOCUMENT } from '@angular/common'; import { BehaviorSubject } from 'rxjs'; import { v4 as uuid } from 'uuid'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import * as Fingerprint2 from 'fingerprintjs2'; @Injectable() -export class PostsService { +export class PostsService implements CanActivate { path = ''; audioFolder = ''; videoFolder = ''; @@ -25,26 +27,83 @@ export class PostsService { session_id = null; httpOptions = null; http_params: string = null; + unauthorized = false; debugMode = false; - constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document) { + + // must be reset after logout + isLoggedIn = false; + token = null; + user = null; + permissions = null; + + available_permissions = null; + + reload_config = new BehaviorSubject(false); + config_reloaded = new BehaviorSubject(false); + service_initialized = new BehaviorSubject(false); + initialized = false; + + open_create_default_admin_dialog = new BehaviorSubject(false); + + config = null; + constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document, + public snackBar: MatSnackBar) { console.log('PostsService Initialized...'); // this.startPath = window.location.href + '/api/'; // this.startPathSSL = window.location.href + '/api/'; this.path = this.document.location.origin + '/api/'; - this.session_id = uuid(); + if (isDevMode()) { this.debugMode = true; this.path = 'http://localhost:17442/api/'; } - this.http_params = `apiKey=${this.auth_token}&sessionID=${this.session_id}` + this.http_params = `apiKey=${this.auth_token}` this.httpOptions = { params: new HttpParams({ fromString: this.http_params }), }; + + Fingerprint2.get(components => { + // set identity as user id doesn't necessarily exist + this.session_id = Fingerprint2.x64hash128(components.map(function (pair) { return pair.value; }).join(), 31); + this.httpOptions.params = this.httpOptions.params.set('sessionID', this.session_id); + }); + + // get config + this.loadNavItems().subscribe(res => { + const result = !this.debugMode ? res['config_file'] : res; + if (result) { + this.config = result['YoutubeDLMaterial']; + if (this.config['Advanced']['multi_user_mode']) { + // login stuff + if (localStorage.getItem('jwt_token')) { + this.token = localStorage.getItem('jwt_token'); + this.httpOptions.params = this.httpOptions.params.set('jwt', this.token); + + this.jwtAuth(); + } else { + this.sendToLogin(); + } + } else { + this.setInitialized(); + } + } + }); + + this.reload_config.subscribe(yes_reload => { + if (yes_reload) { this.reloadConfig(); } + }); + } + canActivate(route, state): Promise { + return new Promise(resolve => { + resolve(true); + }) + console.log(route); + throw new Error('Method not implemented.'); } setTheme(theme) { @@ -59,6 +118,16 @@ export class PostsService { return this.http.get(url + 'geturl'); } + reloadConfig() { + this.loadNavItems().subscribe(res => { + const result = !this.debugMode ? res['config_file'] : res; + if (result) { + this.config = result['YoutubeDLMaterial']; + this.config_reloaded.next(true); + } + }); + } + getVideoFolder() { return this.http.get(this.startPath + 'videofolder'); } @@ -131,18 +200,21 @@ export class PostsService { return this.http.get(this.path + 'getMp4s', this.httpOptions); } - getFile(uid, type) { - return this.http.post(this.path + 'getFile', {uid: uid, type: type}, this.httpOptions); + getFile(uid, type, uuid = null) { + return this.http.post(this.path + 'getFile', {uid: uid, type: type, uuid: uuid}, this.httpOptions); } - downloadFileFromServer(fileName, type, outputName = null, fullPathProvided = null, subscriptionName = null, subPlaylist = null) { + downloadFileFromServer(fileName, type, outputName = null, fullPathProvided = null, subscriptionName = null, subPlaylist = null, + uid = null, uuid = null) { return this.http.post(this.path + 'downloadFile', {fileNames: fileName, type: type, zip_mode: Array.isArray(fileName), outputName: outputName, fullPathProvided: fullPathProvided, subscriptionName: subscriptionName, - subPlaylist: subPlaylist + subPlaylist: subPlaylist, + uuid: uuid, + uid: uid }, {responseType: 'blob', params: this.httpOptions.params}); } @@ -257,4 +329,159 @@ export class PostsService { return this.http.get('https://api.github.com/repos/tzahi12345/youtubedl-material/releases'); } + afterLogin(user, token, permissions, available_permissions) { + this.isLoggedIn = true; + this.user = user; + this.permissions = permissions; + this.available_permissions = available_permissions; + this.token = token; + + localStorage.setItem('jwt_token', this.token); + + this.httpOptions.params = this.httpOptions.params.set('jwt', this.token); + + // needed to re-initialize parts of app after login + this.config_reloaded.next(true); + + if (this.router.url === '/login') { + this.router.navigate(['/home']); + } + } + + // user methods + login(username, password) { + const call = this.http.post(this.path + 'auth/login', {userid: username, password: password}, this.httpOptions); + call.subscribe(res => { + if (res['token']) { + this.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions']); + } + }); + return call; + } + + // user methods + jwtAuth() { + const call = this.http.post(this.path + 'auth/jwtAuth', {}, this.httpOptions); + call.subscribe(res => { + if (res['token']) { + this.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions']); + this.setInitialized(); + } + }, err => { + if (err.status === 401) { + this.sendToLogin(); + } + }); + return call; + } + + logout() { + this.user = null; + this.permissions = null; + this.isLoggedIn = false; + localStorage.setItem('jwt_token', null); + if (this.router.url !== '/login') { + this.router.navigate(['/login']); + } + + // resets http params + this.http_params = `apiKey=${this.auth_token}&sessionID=${this.session_id}` + + this.httpOptions = { + params: new HttpParams({ + fromString: this.http_params + }), + }; + } + + // user methods + register(username, password) { + const call = this.http.post(this.path + 'auth/register', {userid: username, + username: username, + password: password}, this.httpOptions); + /*call.subscribe(res => { + console.log(res['user']); + if (res['user']) { + // this.afterRegistration(res['user']); + } + });*/ + return call; + } + + sendToLogin() { + this.checkAdminCreationStatus(); + if (this.router.url === '/login') { + return; + } + + this.router.navigate(['/login']); + + // send login notification + this.openSnackBar('You must log in to access this page!'); + } + + setInitialized() { + this.service_initialized.next(true); + this.initialized = true; + this.config_reloaded.next(true); + } + + adminExists() { + return this.http.post(this.path + 'auth/adminExists', {}, this.httpOptions); + } + + createAdminAccount(password) { + return this.http.post(this.path + 'auth/register', {userid: 'admin', + username: 'admin', + password: password}, this.httpOptions); + } + + checkAdminCreationStatus() { + if (!this.config['Advanced']['multi_user_mode']) { + return; + } + this.adminExists().subscribe(res => { + if (!res['exists']) { + // must create admin account + this.open_create_default_admin_dialog.next(true); + } + }); + } + + changeUser(change_obj) { + return this.http.post(this.path + 'changeUser', {change_object: change_obj}, this.httpOptions); + } + + deleteUser(uid) { + return this.http.post(this.path + 'deleteUser', {uid: uid}, this.httpOptions); + } + + changeUserPassword(user_uid, new_password) { + return this.http.post(this.path + 'auth/changePassword', {user_uid: user_uid, new_password: new_password}, this.httpOptions); + } + + getUsers() { + return this.http.post(this.path + 'getUsers', {}, this.httpOptions); + } + + getRoles() { + return this.http.post(this.path + 'getRoles', {}, this.httpOptions); + } + + setUserPermission(user_uid, permission, new_value) { + return this.http.post(this.path + 'changeUserPermissions', {user_uid: user_uid, permission: permission, new_value: new_value}, + this.httpOptions); + } + + setRolePermission(role_name, permission, new_value) { + return this.http.post(this.path + 'changeRolePermissions', {role: role_name, permission: permission, new_value: new_value}, + this.httpOptions); + } + + public openSnackBar(message: string, action: string = '') { + this.snackBar.open(message, action, { + duration: 2000, + }); + } + } diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index 2335182..b2696d5 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -166,7 +166,7 @@ Allow multi-download mode
- Require pin for settings + Require pin for settings
@@ -252,9 +252,12 @@ -
+
Allow advanced download
+
+ Multi-user mode +
@@ -263,6 +266,9 @@ + + + diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index 57a14b0..c31c28a 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -39,7 +39,7 @@ export class SettingsComponent implements OnInit { this._settingsSame = val; } - constructor(private postsService: PostsService, private snackBar: MatSnackBar, private sanitizer: DomSanitizer, + constructor(public postsService: PostsService, private snackBar: MatSnackBar, private sanitizer: DomSanitizer, private dialog: MatDialog) { } ngOnInit() { @@ -51,14 +51,8 @@ export class SettingsComponent implements OnInit { } getConfig() { - this.loading_config = true; - this.postsService.loadNavItems().subscribe(res => { - this.loading_config = false; - // successfully loaded config - - this.initial_config = !this.postsService.debugMode ? res['config_file']['YoutubeDLMaterial'] : res['YoutubeDLMaterial']; - this.new_config = JSON.parse(JSON.stringify(this.initial_config)); - }); + this.initial_config = this.postsService.config; + this.new_config = JSON.parse(JSON.stringify(this.initial_config)); } settingsSame() { @@ -69,9 +63,13 @@ export class SettingsComponent implements OnInit { const settingsToSave = {'YoutubeDLMaterial': this.new_config}; this.postsService.setConfig(settingsToSave).subscribe(res => { if (res['success']) { + if (!this.initial_config['Advanced']['multi_user_mode'] && this.new_config['Advanced']['multi_user_mode']) { + // multi user mode was enabled, let's check if default admin account exists + this.postsService.checkAdminCreationStatus(); + } // sets new config as old config - this.postsService.settings_changed.next(true); this.initial_config = JSON.parse(JSON.stringify(this.new_config)); + this.postsService.reload_config.next(true); } }, err => { console.error('Failed to save config!'); diff --git a/src/app/subscription/subscription/subscription.component.ts b/src/app/subscription/subscription/subscription.component.ts index 3c7545f..2226ba6 100644 --- a/src/app/subscription/subscription/subscription.component.ts +++ b/src/app/subscription/subscription/subscription.component.ts @@ -49,8 +49,12 @@ export class SubscriptionComponent implements OnInit { if (this.route.snapshot.paramMap.get('id')) { this.id = this.route.snapshot.paramMap.get('id'); - this.getSubscription(); - this.getConfig(); + this.postsService.service_initialized.subscribe(init => { + if (init) { + this.getConfig(); + this.getSubscription(); + } + }); } // set filter property to cached @@ -78,10 +82,7 @@ export class SubscriptionComponent implements OnInit { } getConfig() { - this.postsService.loadNavItems().subscribe(res => { - const result = !this.postsService.debugMode ? res['config_file'] : res; - this.use_youtubedl_archive = result['YoutubeDLMaterial']['Subscriptions']['subscriptions_use_youtubedl_archive']; - }); + this.use_youtubedl_archive = this.postsService.config['Subscriptions']['subscriptions_use_youtubedl_archive']; } goToFile(emit_obj) { @@ -92,7 +93,7 @@ export class SubscriptionComponent implements OnInit { this.router.navigate(['/player', {name: name, url: url}]); } else { this.router.navigate(['/player', {fileNames: name, type: 'subscription', subscriptionName: this.subscription.name, - subPlaylist: this.subscription.isPlaylist}]); + subPlaylist: this.subscription.isPlaylist, uuid: this.postsService.user ? this.postsService.user.uid : null}]); } } diff --git a/src/app/subscriptions/subscriptions.component.ts b/src/app/subscriptions/subscriptions.component.ts index 9776047..1723bfd 100644 --- a/src/app/subscriptions/subscriptions.component.ts +++ b/src/app/subscriptions/subscriptions.component.ts @@ -22,16 +22,23 @@ export class SubscriptionsComponent implements OnInit { constructor(private dialog: MatDialog, public postsService: PostsService, private router: Router, private snackBar: MatSnackBar) { } ngOnInit() { - this.getSubscriptions(); + if (this.postsService.initialized) { + this.getSubscriptions(); + } + this.postsService.service_initialized.subscribe(init => { + if (init) { + this.getSubscriptions(); + } + }); } getSubscriptions() { this.subscriptions_loading = true; this.subscriptions = null; - this.channel_subscriptions = []; - this.playlist_subscriptions = []; this.postsService.getAllSubscriptions().subscribe(res => { - this.subscriptions_loading = false; + this.channel_subscriptions = []; + this.playlist_subscriptions = []; + this.subscriptions_loading = false; this.subscriptions = res['subscriptions']; if (!this.subscriptions) { // set it to an empty array so it can notify the user there are no subscriptions diff --git a/src/assets/default.json b/src/assets/default.json index ad4211c..f1ed23a 100644 --- a/src/assets/default.json +++ b/src/assets/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": true, "allow_advanced_download": true } }