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('bcryptjs'); var LocalStrategy = require('passport-local').Strategy; var LdapStrategy = require('passport-ldapauth'); var JwtStrategy = require('passport-jwt').Strategy, ExtractJwt = require('passport-jwt').ExtractJwt; // other required vars let logger = null; let db = null; let users_db = null; let SERVER_SECRET = null; let JWT_EXPIRATION = null; let opts = null; let saltRounds = null; exports.initialize = function(input_db, input_users_db, input_logger) { setLogger(input_logger) setDB(input_db, input_users_db); /************************* * Authentication module ************************/ saltRounds = 10; JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration'); 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}).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_db, input_users_db) { db = input_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; if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) { res.sendStatus(409); logger.error(`Registration failed for user ${userid}. Registration is disabled.`); return; } if (plaintextPassword === "") { res.sendStatus(400); logger.error(`Registration failed for user ${userid}. A password must be provided.`); return; } bcrypt.hash(plaintextPassword, saltRounds) .then(function(hash) { let new_user = generateUserObject(userid, username, hash); // 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.login = async (username, password) => { const user = users_db.get('users').find({name: username}).value(); if (!user) { logger.error(`User ${username} not found`); false } if (user.auth_method && user.auth_method !== 'internal') { return false } return await bcrypt.compare(password, user.passhash) ? user : false; } exports.passport.use(new LocalStrategy({ usernameField: 'username', passwordField: 'password'}, async function(username, password, done) { return done(null, await exports.login(username, password)); } )); var getLDAPConfiguration = function(req, callback) { const ldap_config = config_api.getConfigItem('ytdl_ldap_config'); const opts = {server: ldap_config}; callback(null, opts); }; exports.passport.use(new LdapStrategy(getLDAPConfiguration, function(user, done) { // check if ldap auth is enabled const ldap_enabled = config_api.getConfigItem('ytdl_auth_method') === 'ldap'; if (!ldap_enabled) return done(null, false); const user_uid = user.uid; let db_user = users_db.get('users').find({uid: user_uid}).value(); if (!db_user) { // generate DB user let new_user = generateUserObject(user_uid, user_uid, null, 'ldap'); users_db.get('users').push(new_user).write(); db_user = new_user; logger.verbose(`Generated new user ${user_uid} using LDAP`); } return done(null, db_user); } )); /********************************** * 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.uid }; 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) { try { const hash = await bcrypt.hash(new_pass, saltRounds); users_db.get('users').find({uid: user_uid}).assign({passhash: hash}).write(); return true; } catch (err) { return 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 type ? user['files'].filter(file => file.isAudio === (type === 'audio')) : user['files']; } exports.getUserVideo = function(user_uid, file_uid, requireSharing = false) { let file = users_db.get('users').find({uid: user_uid}).get(`files`).find({uid: file_uid}).value(); // prevent unauthorized users from accessing the file info if (file && !file['sharingEnabled'] && requireSharing) file = null; return file; } exports.addPlaylist = function(user_uid, new_playlist) { users_db.get('users').find({uid: user_uid}).get(`playlists`).push(new_playlist).write(); return true; } exports.updatePlaylistFiles = function(user_uid, playlistID, new_filenames) { users_db.get('users').find({uid: user_uid}).get(`playlists`).find({id: playlistID}).assign({fileNames: new_filenames}); return true; } exports.removePlaylist = function(user_uid, playlistID) { users_db.get('users').find({uid: user_uid}).get(`playlists`).remove({id: playlistID}).write(); return true; } exports.getUserPlaylists = function(user_uid, user_files = null) { const user = users_db.get('users').find({uid: user_uid}).value(); const playlists = JSON.parse(JSON.stringify(user['playlists'])); const categories = db.get('categories').value(); if (categories && user_files) { categories.forEach(category => { const audio_files = user_files && user_files.filter(file => file.category && file.category.uid === category.uid && file.isAudio); const video_files = user_files && user_files.filter(file => file.category && file.category.uid === category.uid && !file.isAudio); if (audio_files && audio_files.length > 0) { playlists.push({ name: category['name'], thumbnailURL: audio_files[0].thumbnailURL, thumbnailPath: audio_files[0].thumbnailPath, fileNames: audio_files.map(file => file.id), type: 'audio', uid: user_uid, auto: true }); } if (video_files && video_files.length > 0) { playlists.push({ name: category['name'], thumbnailURL: video_files[0].thumbnailURL, thumbnailPath: video_files[0].thumbnailPath, fileNames: video_files.map(file => file.id), type: 'video', uid: user_uid, auto: true }); } }); } return playlists; } exports.getUserPlaylist = function(user_uid, playlistID, requireSharing = false) { let playlist = users_db.get('users').find({uid: user_uid}).get(`playlists`).find({id: playlistID}).value(); // prevent unauthorized users from accessing the file info if (requireSharing && !playlist['sharingEnabled']) playlist = null; return playlist; } exports.registerUserFile = function(user_uid, file_object) { users_db.get('users').find({uid: user_uid}).get(`files`) .remove({ path: file_object['path'] }).write(); users_db.get('users').find({uid: user_uid}).get(`files`) .push(file_object) .write(); } exports.deleteUserFile = async function(user_uid, file_uid, blacklistMode = false) { let success = false; const file_obj = users_db.get('users').find({uid: user_uid}).get(`files`).find({uid: file_uid}).value(); if (file_obj) { const type = file_obj.isAudio ? 'audio' : 'video'; 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`) .remove({ uid: file_uid }).write(); if (await fs.pathExists(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 (await fs.pathExists(json_path)) { youtube_id = await fs.readJSON(json_path).id; await fs.unlink(json_path); } else if (await fs.pathExists(alternate_json_path)) { youtube_id = await fs.readJSON(alternate_json_path).id; await fs.unlink(alternate_json_path); } await fs.unlink(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 (await fs.pathExists(archive_path)) { const line = youtube_id ? await 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; await fs.appendFile(blacklistPath, line); } } else { logger.info(`Could not find archive file for ${type} files. Creating...`); await fs.ensureFile(archive_path); } } } success = true; } else { success = false; logger.warn(`User file ${file_uid} does not exist!`); } return success; } exports.changeSharingMode = function(user_uid, file_uid, 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`).find({id: file_uid}) : user_db_obj.get(`files`).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; } }; function generateUserObject(userid, username, hash, auth_method = 'internal') { let new_user = { name: username, uid: userid, passhash: auth_method === 'internal' ? hash : null, files: [], playlists: [], subscriptions: [], created: Date.now(), role: userid === 'admin' && auth_method === 'internal' ? 'admin' : 'user', permissions: [], permission_overrides: [], auth_method: auth_method }; return new_user; }