feat: add OIDC auth flow with multi-user enforcement

pull/1163/head
voc0der 2 months ago
parent 90dd796f18
commit a84d08e64f

@ -4,6 +4,7 @@ const { promisify } = require('util');
const http = require('http');
const https = require('https');
const auth_api = require('./authentication/auth');
const oidc_api = require('./authentication/oidc');
const path = require('path');
const compression = require('compression');
const multer = require('multer');
@ -637,9 +638,57 @@ async function setPortItemFromENV() {
return true;
}
function getOIDCMigrateTargetFromEnv() {
return process.env.ytdl_oidc_migrate_videos || process.env.YTDL_OIDC_MIGRATE_VIDEOS || null;
}
function getScopedFilterByUser(user_uid) {
if (!config_api.getConfigItem('ytdl_multi_user_mode')) return {};
return {user_uid: user_uid};
}
async function migrateUnassignedVideosToConfiguredUser() {
const migrate_target = getOIDCMigrateTargetFromEnv();
if (!migrate_target) return true;
const normalized_target = String(migrate_target).trim();
const safe_uid = auth_api.sanitizeUserUID(normalized_target);
if (!safe_uid) {
throw new Error(`Invalid ytdl_oidc_migrate_videos value '${normalized_target}'. It must be a valid uid.`);
}
let target_user = await db_api.getRecord('users', {uid: safe_uid});
if (!target_user) {
target_user = await db_api.getRecord('users', {name: normalized_target});
}
if (!target_user) {
throw new Error(`ytdl_oidc_migrate_videos requested migration to '${normalized_target}', but no such user exists.`);
}
const unassigned_filter = {user_uid: null};
const unassigned_count = await db_api.getRecords('files', unassigned_filter, true);
if (!unassigned_count) {
throw new Error('No unassigned videos/files exist for ytdl_oidc_migrate_videos. Remove this env variable to continue startup.');
}
const migrated = await db_api.updateRecords('files', unassigned_filter, {user_uid: target_user.uid});
if (!migrated) {
throw new Error(`Failed to migrate unassigned videos/files to user '${target_user.uid}'.`);
}
logger.info(`Migrated ${unassigned_count} unassigned videos/files to user '${target_user.uid}' from ytdl_oidc_migrate_videos.`);
return true;
}
async function setAndLoadConfig() {
await setConfigFromEnv();
await loadConfig();
try {
await setConfigFromEnv();
await loadConfig();
} catch (err) {
logger.error(`Startup failed: ${err.message}`);
process.exit(1);
}
}
async function setConfigFromEnv() {
@ -659,6 +708,19 @@ async function setConfigFromEnv() {
async function loadConfig() {
loadConfigValues();
const oidc_enabled = oidc_api.isEnabled();
if (oidc_enabled && !config_api.getConfigItem('ytdl_multi_user_mode')) {
logger.error('OIDC startup failed: multi-user mode must be enabled when OIDC is enabled.');
process.exit(1);
}
try {
await oidc_api.initialize();
} catch (err) {
logger.error(`OIDC startup failed: ${err.message}`);
process.exit(1);
}
// connect to DB
if (!config_api.getConfigItem('ytdl_use_local_db'))
await db_api.connectToDB();
@ -667,6 +729,7 @@ async function loadConfig() {
// check migrations
await checkMigrations();
await migrateUnassignedVideosToConfiguredUser();
// now this is done here due to youtube-dl's repo takedown
await startYoutubeDL();
@ -751,7 +814,7 @@ function getEnvConfigItems() {
let config_item_keys = Object.keys(config_api.CONFIG_ITEMS);
for (let i = 0; i < config_item_keys.length; i++) {
let key = config_item_keys[i];
if (process['env'][key]) {
if (process['env'][key] || process['env'][key.toUpperCase()]) {
const config_item = generateEnvVarConfigItem(key);
config_items.push(config_item);
}
@ -762,7 +825,7 @@ function getEnvConfigItems() {
// gets value of a config item and stores it in an object
function generateEnvVarConfigItem(key) {
return {key: key, value: process['env'][key]};
return {key: key, value: process['env'][key] || process['env'][key.toUpperCase()]};
}
// youtube-dl functions
@ -785,6 +848,10 @@ app.use(function(req, res, next) {
app.use(function(req, res, next) {
if (!req.path.includes('/api/')) {
next();
} else if (req.path.includes('/api/auth/oidc/login') ||
req.path.includes('/api/auth/oidc/callback') ||
req.path.includes('/api/auth/oidc/status')) {
next();
} else if (req.query.apiKey === admin_token) {
next();
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
@ -843,6 +910,13 @@ const authRateLimiter = rateLimit({
app.use('/api', apiRateLimiter);
app.use('/api/auth', authRateLimiter);
function isPublicAuthPath(req_path) {
return req_path.includes('/api/auth/register')
|| req_path.includes('/api/auth/oidc/login')
|| req_path.includes('/api/auth/oidc/callback')
|| req_path.includes('/api/auth/oidc/status');
}
const optionalJwt = async function (req, res, next) {
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
if (multiUserMode && ((req.body && req.body.uuid) || (req.query && req.query.uuid)) && (req.path.includes('/api/getFile') ||
@ -862,7 +936,7 @@ const optionalJwt = async function (req, res, next) {
res.sendStatus(401);
return;
}
} else if (multiUserMode && !(req.path.includes('/api/auth/register') && !(req.path.includes('/api/config')) && !req.query.jwt)) { // registration should get passed through
} else if (multiUserMode && !isPublicAuthPath(req.path)) {
if (!req.query.jwt) {
res.sendStatus(401);
return;
@ -1035,10 +1109,14 @@ app.get('/api/getMp4s', optionalJwt, async function(req, res) {
app.post('/api/getFile', optionalJwt, async function (req, res) {
const uid = req.body.uid;
const uuid = req.body.uuid;
let file = await db_api.getRecord('files', {uid: uid});
if (uuid && !file['sharingEnabled']) file = null;
let file = null;
if (req.isAuthenticated()) {
file = await files_api.getVideo(uid, req.user.uid);
} else if (uuid) {
file = await auth_api.getUserVideo(uuid, uid, true);
} else {
file = await files_api.getVideo(uid);
}
// check if chat exists for twitch videos
if (file && file['url'].includes('twitch.tv')) file['chat_exists'] = fs.existsSync(file['path'].substring(0, file['path'].length - 4) + '.twitch_chat.json');
@ -1126,7 +1204,10 @@ app.post('/api/getFullTwitchChat', optionalJwt, async (req, res) => {
var sub = req.body.sub;
var user_uid = null;
if (req.isAuthenticated()) user_uid = req.user.uid;
if (req.isAuthenticated()) {
user_uid = req.user.uid;
uuid = req.user.uid;
}
const chat_file = await twitch_api.getTwitchChatByFileID(id, type, user_uid, uuid, sub);
@ -1143,7 +1224,10 @@ app.post('/api/downloadTwitchChatByVODID', optionalJwt, async (req, res) => {
var sub = req.body.sub;
var user_uid = null;
if (req.isAuthenticated()) user_uid = req.user.uid;
if (req.isAuthenticated()) {
user_uid = req.user.uid;
uuid = req.user.uid;
}
// check if file already exists. if so, send that instead
const file_exists_check = await twitch_api.getTwitchChatByFileID(id, type, user_uid, uuid, sub);
@ -1227,9 +1311,7 @@ app.post('/api/incrementViewCount', async (req, res) => {
let sub_id = req.body.sub_id;
let uuid = req.body.uuid;
if (!uuid && req.isAuthenticated()) {
uuid = req.user.uid;
}
if (req.isAuthenticated()) uuid = req.user.uid;
const file_obj = await files_api.getVideo(file_uid, uuid, sub_id);
@ -1342,7 +1424,7 @@ app.post('/api/unsubscribe', optionalJwt, async (req, res) => {
let sub_id = req.body.sub_id;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
let result_obj = subscriptions_api.unsubscribe(sub_id, deleteMode, user_uid);
let result_obj = await subscriptions_api.unsubscribe(sub_id, deleteMode, user_uid);
if (result_obj.success) {
res.send({
success: result_obj.success
@ -1358,8 +1440,9 @@ app.post('/api/unsubscribe', optionalJwt, async (req, res) => {
app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => {
let deleteForever = req.body.deleteForever;
let file_uid = req.body.file_uid;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
let success = await files_api.deleteFile(file_uid, deleteForever);
let success = await files_api.deleteFile(file_uid, deleteForever, user_uid);
if (success) {
res.send({
@ -1374,13 +1457,14 @@ app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => {
app.post('/api/getSubscription', optionalJwt, async (req, res) => {
let subID = req.body.id;
let subName = req.body.name; // if included, subID is optional
let user_uid = req.isAuthenticated() ? req.user.uid : null;
// get sub from db
let subscription = null;
if (subID) {
subscription = await subscriptions_api.getSubscription(subID)
subscription = await subscriptions_api.getSubscription(subID, user_uid)
} else if (subName) {
subscription = await subscriptions_api.getSubscriptionByName(subName)
subscription = await subscriptions_api.getSubscriptionByName(subName, user_uid)
}
if (!subscription) {
@ -1393,7 +1477,8 @@ app.post('/api/getSubscription', optionalJwt, async (req, res) => {
// get sub videos
if (subscription.name) {
var parsed_files = await db_api.getRecords('files', {sub_id: subscription.id}); // subscription.videos;
const sub_files_filter = {sub_id: subscription.id, ...getScopedFilterByUser(user_uid)};
var parsed_files = await db_api.getRecords('files', sub_files_filter); // subscription.videos;
subscription['videos'] = parsed_files;
// loop through files for extra processing
for (let i = 0; i < parsed_files.length; i++) {
@ -1413,8 +1498,13 @@ app.post('/api/getSubscription', optionalJwt, async (req, res) => {
app.post('/api/downloadVideosForSubscription', optionalJwt, async (req, res) => {
const subID = req.body.subID;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const sub = subscriptions_api.getSubscription(subID);
const sub = await subscriptions_api.getSubscription(subID, user_uid);
if (!sub) {
res.send({success: false});
return;
}
subscriptions_api.getVideosForSub(sub.id);
res.send({
success: true
@ -1423,8 +1513,9 @@ app.post('/api/downloadVideosForSubscription', optionalJwt, async (req, res) =>
app.post('/api/updateSubscription', optionalJwt, async (req, res) => {
const updated_sub = req.body.subscription;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const success = subscriptions_api.updateSubscription(updated_sub);
const success = await subscriptions_api.updateSubscription(updated_sub, user_uid);
res.send({
success: success
});
@ -1434,7 +1525,7 @@ app.post('/api/checkSubscription', optionalJwt, async (req, res) => {
let sub_id = req.body.sub_id;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
const success = subscriptions_api.getVideosForSub(sub_id, user_uid);
const success = await subscriptions_api.getVideosForSub(sub_id, user_uid);
res.send({
success: success
});
@ -1444,7 +1535,7 @@ app.post('/api/cancelCheckSubscription', optionalJwt, async (req, res) => {
let sub_id = req.body.sub_id;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
const success = subscriptions_api.cancelCheckSubscription(sub_id, user_uid);
const success = await subscriptions_api.cancelCheckSubscription(sub_id, user_uid);
res.send({
success: success
});
@ -1454,7 +1545,7 @@ app.post('/api/cancelSubscriptionCheck', optionalJwt, async (req, res) => {
let sub_id = req.body.sub_id;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
const success = subscriptions_api.getVideosForSub(sub_id, user_uid);
const success = await subscriptions_api.getVideosForSub(sub_id, user_uid);
res.send({
success: success
});
@ -1487,6 +1578,7 @@ app.post('/api/getPlaylist', optionalJwt, async (req, res) => {
let playlist_id = req.body.playlist_id;
let uuid = req.body.uuid ? req.body.uuid : (req.user && req.user.uid ? req.user.uid : null);
let include_file_metadata = req.body.include_file_metadata;
if (req.user && req.user.uid) uuid = req.user.uid;
const playlist = await files_api.getPlaylist(playlist_id, uuid);
const file_objs = [];
@ -1510,8 +1602,9 @@ app.post('/api/getPlaylist', optionalJwt, async (req, res) => {
app.post('/api/getPlaylists', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const include_categories = req.body.include_categories;
const filter_obj = getScopedFilterByUser(uuid);
let playlists = await db_api.getRecords('playlists', {user_uid: uuid});
let playlists = await db_api.getRecords('playlists', filter_obj);
if (include_categories) {
const categories = await categories_api.getCategoriesAsPlaylists();
if (categories) {
@ -1528,11 +1621,25 @@ app.post('/api/addFileToPlaylist', optionalJwt, async (req, res) => {
let playlist_id = req.body.playlist_id;
let file_uid = req.body.file_uid;
const playlist = await db_api.getRecord('playlists', {id: playlist_id});
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const playlist = await files_api.getPlaylist(playlist_id, user_uid);
if (!playlist) {
res.send({
success: false
});
return;
}
const file_obj = await files_api.getVideo(file_uid, user_uid);
if (!file_obj) {
res.send({
success: false
});
return;
}
playlist.uids.push(file_uid);
let success = await files_api.updatePlaylist(playlist);
let success = await files_api.updatePlaylist(playlist, user_uid);
res.send({
success: success
});
@ -1548,11 +1655,13 @@ app.post('/api/updatePlaylist', optionalJwt, async (req, res) => {
app.post('/api/deletePlaylist', optionalJwt, async (req, res) => {
let playlistID = req.body.playlist_id;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
let success = null;
try {
// removes playlist from playlists
await db_api.removeRecord('playlists', {id: playlistID})
const filter_obj = {id: playlistID, ...getScopedFilterByUser(user_uid)};
await db_api.removeRecord('playlists', filter_obj)
success = true;
} catch(e) {
@ -1568,9 +1677,10 @@ app.post('/api/deletePlaylist', optionalJwt, async (req, res) => {
app.post('/api/deleteFile', optionalJwt, async (req, res) => {
const uid = req.body.uid;
const blacklistMode = req.body.blacklistMode;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
let wasDeleted = false;
wasDeleted = await files_api.deleteFile(uid, blacklistMode);
wasDeleted = await files_api.deleteFile(uid, blacklistMode, user_uid);
res.send(wasDeleted);
});
@ -1582,7 +1692,7 @@ app.post('/api/deleteAllFiles', optionalJwt, async (req, res) => {
let text_search = req.body.text_search;
let file_type_filter = req.body.file_type_filter;
const filter_obj = {user_uid: uuid};
const filter_obj = getScopedFilterByUser(uuid);
const regex = true;
if (text_search) {
if (regex) {
@ -1602,7 +1712,7 @@ app.post('/api/deleteAllFiles', optionalJwt, async (req, res) => {
for (let i = 0; i < files.length; i++) {
let wasDeleted = false;
wasDeleted = await files_api.deleteFile(files[i].uid, blacklistMode);
wasDeleted = await files_api.deleteFile(files[i].uid, blacklistMode, uuid);
if (wasDeleted) {
delete_count++;
}
@ -1622,30 +1732,42 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
let file_path_to_download = null;
if (!uuid && req.user) uuid = req.user.uid;
if (req.user && req.user.uid) uuid = req.user.uid;
let zip_file_generated = false;
if (playlist_id) {
zip_file_generated = true;
const playlist_files_to_download = [];
const playlist = await files_api.getPlaylist(playlist_id, uuid);
if (!playlist) {
res.sendStatus(404);
return;
}
for (let i = 0; i < playlist['uids'].length; i++) {
const playlist_file_uid = playlist['uids'][i];
const file_obj = await files_api.getVideo(playlist_file_uid, uuid);
playlist_files_to_download.push(file_obj);
if (file_obj) playlist_files_to_download.push(file_obj);
}
// generate zip
file_path_to_download = await utils.createContainerZipFile(playlist['name'], playlist_files_to_download);
} else if (sub_id && !uid) {
zip_file_generated = true;
const sub = await db_api.getRecord('subscriptions', {id: sub_id});
const sub_files_to_download = await db_api.getRecords('files', {sub_id: sub_id});
const sub = await subscriptions_api.getSubscription(sub_id, req.isAuthenticated() ? req.user.uid : null);
if (!sub) {
res.sendStatus(404);
return;
}
const sub_files_to_download = await db_api.getRecords('files', {sub_id: sub_id, ...getScopedFilterByUser(req.isAuthenticated() ? req.user.uid : null)});
// generate zip
file_path_to_download = await utils.createContainerZipFile(sub['name'], sub_files_to_download);
} else {
const file_obj = await files_api.getVideo(uid, uuid, sub_id)
if (!file_obj) {
res.sendStatus(404);
return;
}
file_path_to_download = file_obj.path;
}
if (!path.isAbsolute(file_path_to_download)) file_path_to_download = path.join(__dirname, file_path_to_download);
@ -1666,7 +1788,7 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
app.post('/api/getArchives', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const sub_id = req.body.sub_id;
const filter_obj = {user_uid: uuid, sub_id: sub_id};
const filter_obj = {sub_id: sub_id, ...getScopedFilterByUser(uuid)};
const type = req.body.type;
// we do this for file types because if type is null, that means get files of all types
@ -1990,7 +2112,7 @@ app.post('/api/generateNewAPIKey', optionalJwt, function (req, res) {
app.get('/api/stream', optionalJwt, async (req, res) => {
const type = req.query.type;
const uuid = req.query.uuid ? req.query.uuid : (req.user ? req.user.uid : null);
const uuid = req.user ? req.user.uid : (req.query.uuid ? req.query.uuid : null);
const sub_id = req.query.sub_id;
const mimetype = type === 'audio' ? 'audio/mp3' : 'video/mp4';
var head;
@ -2100,7 +2222,8 @@ app.get('/api/thumbnail/:path', optionalJwt, async (req, res) => {
app.post('/api/downloads', optionalJwt, async (req, res) => {
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const uids = req.body.uids;
let downloads = await db_api.getRecords('download_queue', {user_uid: user_uid});
const filter_obj = getScopedFilterByUser(user_uid);
let downloads = await db_api.getRecords('download_queue', filter_obj);
if (uids) downloads = downloads.filter(download => uids.includes(download['uid']));
@ -2109,8 +2232,10 @@ app.post('/api/downloads', optionalJwt, async (req, res) => {
app.post('/api/download', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const filter_obj = {uid: download_uid, ...getScopedFilterByUser(user_uid)};
const download = await db_api.getRecord('download_queue', {uid: download_uid});
const download = await db_api.getRecord('download_queue', filter_obj);
if (download) {
res.send({download: download});
@ -2121,24 +2246,37 @@ app.post('/api/download', optionalJwt, async (req, res) => {
app.post('/api/clearDownloads', optionalJwt, async (req, res) => {
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const scoped_filter = getScopedFilterByUser(user_uid);
const clear_finished = req.body.clear_finished;
const clear_paused = req.body.clear_paused;
const clear_errors = req.body.clear_errors;
let success = true;
if (clear_finished) success &= await db_api.removeAllRecords('download_queue', {finished: true, user_uid: user_uid, error: null});
if (clear_paused) success &= await db_api.removeAllRecords('download_queue', {paused: true, user_uid: user_uid});
if (clear_errors) success &= await db_api.removeAllRecords('download_queue', {error: {$ne: null}, user_uid: user_uid});
if (clear_finished) success &= await db_api.removeAllRecords('download_queue', {finished: true, ...scoped_filter, error: null});
if (clear_paused) success &= await db_api.removeAllRecords('download_queue', {paused: true, ...scoped_filter});
if (clear_errors) success &= await db_api.removeAllRecords('download_queue', {error: {$ne: null}, ...scoped_filter});
res.send({success: success});
});
app.post('/api/clearDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)});
if (!owned_download) {
res.send({success: false});
return;
}
const success = await downloader_api.clearDownload(download_uid);
res.send({success: success});
});
app.post('/api/pauseDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)});
if (!owned_download) {
res.send({success: false});
return;
}
const success = await downloader_api.pauseDownload(download_uid);
res.send({success: success});
});
@ -2146,7 +2284,7 @@ app.post('/api/pauseDownload', optionalJwt, async (req, res) => {
app.post('/api/pauseAllDownloads', optionalJwt, async (req, res) => {
const user_uid = req.isAuthenticated() ? req.user.uid : null;
let success = true;
const all_running_downloads = await db_api.getRecords('download_queue', {paused: false, finished: false, user_uid: user_uid});
const all_running_downloads = await db_api.getRecords('download_queue', {paused: false, finished: false, ...getScopedFilterByUser(user_uid)});
for (let i = 0; i < all_running_downloads.length; i++) {
success &= await downloader_api.pauseDownload(all_running_downloads[i]['uid']);
}
@ -2155,6 +2293,12 @@ app.post('/api/pauseAllDownloads', optionalJwt, async (req, res) => {
app.post('/api/resumeDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)});
if (!owned_download) {
res.send({success: false});
return;
}
const success = await downloader_api.resumeDownload(download_uid);
res.send({success: success});
});
@ -2162,7 +2306,7 @@ app.post('/api/resumeDownload', optionalJwt, async (req, res) => {
app.post('/api/resumeAllDownloads', optionalJwt, async (req, res) => {
const user_uid = req.isAuthenticated() ? req.user.uid : null;
let success = true;
const all_paused_downloads = await db_api.getRecords('download_queue', {paused: true, user_uid: user_uid, error: null});
const all_paused_downloads = await db_api.getRecords('download_queue', {paused: true, ...getScopedFilterByUser(user_uid), error: null});
for (let i = 0; i < all_paused_downloads.length; i++) {
success &= await downloader_api.resumeDownload(all_paused_downloads[i]['uid']);
}
@ -2171,12 +2315,24 @@ app.post('/api/resumeAllDownloads', optionalJwt, async (req, res) => {
app.post('/api/restartDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)});
if (!owned_download) {
res.send({success: false, new_download_uid: null});
return;
}
const new_download = await downloader_api.restartDownload(download_uid);
res.send({success: !!new_download, new_download_uid: new_download ? new_download['uid'] : null});
});
app.post('/api/cancelDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)});
if (!owned_download) {
res.send({success: false});
return;
}
const success = await downloader_api.cancelDownload(download_uid);
res.send({success: success});
});
@ -2342,7 +2498,72 @@ app.post('/api/clearAllLogs', optionalJwt, async function(req, res) {
// user authentication
app.get('/api/auth/oidc/status', (req, res) => {
res.send(oidc_api.getStatus());
});
app.get('/api/auth/oidc/login', async (req, res) => {
if (!oidc_api.isEnabled()) {
res.status(404).send('OIDC is disabled.');
return;
}
try {
const return_to = req.query.returnTo ? String(req.query.returnTo) : '/home';
const authorization_url = oidc_api.createAuthorizationURL(return_to);
res.redirect(authorization_url);
} catch (err) {
logger.error(`OIDC login redirect failed: ${err.message}`);
res.sendStatus(500);
}
});
app.get('/api/auth/oidc/callback', async (req, res) => {
if (!oidc_api.isEnabled()) {
res.status(404).send('OIDC is disabled.');
return;
}
try {
const callback_result = await oidc_api.consumeAuthorizationCallback(req);
const claims = callback_result.claims || {};
if (!oidc_api.isClaimsAllowed(claims)) {
logger.error('OIDC login rejected: user does not match allowed groups policy.');
res.sendStatus(403);
return;
}
const oidc_config = oidc_api.getConfiguration();
const user_obj = await auth_api.upsertOIDCUser(claims, {
auto_register: oidc_config.auto_register,
admin_claim: oidc_config.admin_claim,
admin_value: oidc_config.admin_value,
groups_claim: oidc_config.groups_claim,
username_claim: oidc_config.username_claim,
display_name_claim: oidc_config.display_name_claim
});
if (!user_obj) {
res.sendStatus(403);
return;
}
const auth_response = await auth_api.getAuthResponseObject(user_obj);
const return_to = callback_result.return_to ? callback_result.return_to : '/home';
const redirect_path = `${getOrigin()}/#/login;oidc_token=${encodeURIComponent(auth_response.token)};redirect=${encodeURIComponent(return_to)}`;
res.setHeader('Set-Cookie', 'ytdl_oidc_bootstrap=1; Path=/; Max-Age=60; HttpOnly; SameSite=Lax');
res.redirect(redirect_path);
} catch (err) {
logger.error(`OIDC callback failed: ${err.message}`);
res.sendStatus(401);
}
});
app.post('/api/auth/register', optionalJwt, async (req, res) => {
if (oidc_api.isEnabled()) {
res.status(403).send('Registration is disabled when OIDC is enabled.');
return;
}
const userid = req.body.userid;
const username = req.body.username;
const plaintextPassword = req.body.password;
@ -2375,6 +2596,13 @@ app.post('/api/auth/register', optionalJwt, async (req, res) => {
});
});
app.post('/api/auth/login'
, (req, res, next) => {
if (oidc_api.isEnabled()) {
res.status(403).send('Password login is disabled when OIDC is enabled.');
return;
}
next();
}
, auth_api.passport.authenticate(['local', 'ldapauth'], { session: false })
, auth_api.generateJWT
, auth_api.returnAuthResponse
@ -2584,6 +2812,37 @@ app.get('/api/rss', async function (req, res) {
// web server
app.use(function(req, res, next) {
if (!oidc_api.isEnabled() || !config_api.getConfigItem('ytdl_multi_user_mode')) {
return next();
}
if (req.path.includes('/api/')) {
return next();
}
const accept = req.accepts('html', 'json', 'xml');
if (accept !== 'html') {
return next();
}
const ext = path.extname(req.path);
if (ext !== '') {
return next();
}
const cookie_header = req.headers.cookie || '';
const has_bootstrap_cookie = cookie_header.split(';').map(cookie => cookie.trim()).includes('ytdl_oidc_bootstrap=1');
if (has_bootstrap_cookie) {
res.setHeader('Set-Cookie', 'ytdl_oidc_bootstrap=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax');
return next();
}
const return_to = req.path && req.path !== '/' ? req.path : '/home';
const redirect_path = `/api/auth/oidc/login?returnTo=${encodeURIComponent(return_to)}`;
return res.redirect(redirect_path);
});
app.use(function(req, res, next) {
//if the request is not html then move along
var accept = req.accepts('html', 'json', 'xml');

@ -74,6 +74,21 @@
"bindCredentials": "secret",
"searchBase": "ou=passport-ldapauth",
"searchFilter": "(uid={{username}})"
},
"oidc": {
"enabled": false,
"issuer_url": "",
"client_id": "",
"client_secret": "",
"redirect_uri": "",
"scope": "openid profile email",
"auto_register": true,
"admin_claim": "groups",
"admin_value": "admin",
"group_claim": "groups",
"allowed_groups": "",
"username_claim": "preferred_username",
"display_name_claim": "preferred_username"
}
},
"Database": {

@ -2,10 +2,14 @@ const path = require('path');
const fs = require('fs-extra');
const { v4: uuid } = require('uuid');
const config_api = require('./config');
const db_api = require('./db');
exports.generateArchive = async (type = null, user_uid = null, sub_id = null) => {
const filter = {user_uid: user_uid, sub_id: sub_id};
const filter = {sub_id: sub_id};
if (config_api.getConfigItem('ytdl_multi_user_mode')) {
filter['user_uid'] = user_uid;
}
if (type) filter['type'] = type;
const archive_items = await db_api.getRecords('archives', filter);
const archive_item_lines = archive_items.map(archive_item => `${archive_item['extractor']} ${archive_item['id']}`);
@ -14,17 +18,29 @@ exports.generateArchive = async (type = null, user_uid = null, sub_id = null) =>
exports.addToArchive = async (extractor, id, type, title, user_uid = null, sub_id = null) => {
const archive_item = createArchiveItem(extractor, id, type, title, user_uid, sub_id);
const success = await db_api.insertRecordIntoTable('archives', archive_item, {extractor: extractor, id: id, type: type});
const replace_filter = {extractor: extractor, id: id, type: type, sub_id: sub_id};
if (config_api.getConfigItem('ytdl_multi_user_mode')) {
replace_filter['user_uid'] = user_uid;
}
const success = await db_api.insertRecordIntoTable('archives', archive_item, replace_filter);
return success;
}
exports.removeFromArchive = async (extractor, id, type, user_uid = null, sub_id = null) => {
const success = await db_api.removeAllRecords('archives', {extractor: extractor, id: id, type: type, user_uid: user_uid, sub_id: sub_id});
const filter = {extractor: extractor, id: id, type: type, sub_id: sub_id};
if (config_api.getConfigItem('ytdl_multi_user_mode')) {
filter['user_uid'] = user_uid;
}
const success = await db_api.removeAllRecords('archives', filter);
return success;
}
exports.existsInArchive = async (extractor, id, type, user_uid, sub_id) => {
const archive_item = await db_api.getRecord('archives', {extractor: extractor, id: id, type: type, user_uid: user_uid, sub_id: sub_id});
const filter = {extractor: extractor, id: id, type: type, sub_id: sub_id};
if (config_api.getConfigItem('ytdl_multi_user_mode')) {
filter['user_uid'] = user_uid;
}
const archive_item = await db_api.getRecord('archives', filter);
return !!archive_item;
}
@ -88,4 +104,4 @@ const createArchiveItem = (extractor, id, type, title = null, user_uid = null, s
timestamp: Date.now() / 1000,
uid: uuid()
}
}
}

@ -20,6 +20,8 @@ let JWT_EXPIRATION = null;
let opts = null;
let saltRounds = 10;
const SAFE_UID_PATTERN = /^[A-Za-z0-9._@-]+$/;
exports.initialize = function () {
/*************************
* Authentication module
@ -127,6 +129,153 @@ exports.registerUser = async (userid, username, plaintextPassword) => {
}
}
function parseClaimPath(claims, claimPath) {
if (!claims || !claimPath || typeof claimPath !== 'string') return undefined;
const pathParts = claimPath.split('.').filter(part => part !== '');
if (pathParts.length === 0) return undefined;
let currentValue = claims;
for (const part of pathParts) {
if (!currentValue || typeof currentValue !== 'object' || !(part in currentValue)) {
return undefined;
}
currentValue = currentValue[part];
}
return currentValue;
}
function claimToArray(value) {
if (value === undefined || value === null) return [];
if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(v => v.length > 0);
if (typeof value === 'string' && value.includes(',')) {
return value.split(',').map(v => v.trim()).filter(v => v.length > 0);
}
const normalized = String(value).trim();
return normalized ? [normalized] : [];
}
function valueIncludes(expectedValue, sourceValue) {
if (!expectedValue || expectedValue.length === 0) return false;
const expected = String(expectedValue).trim().toLowerCase();
if (!expected) return false;
return claimToArray(sourceValue).some(entry => entry.toLowerCase() === expected);
}
function claimValueToString(value) {
if (value === undefined || value === null) return '';
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return '';
}
exports.sanitizeUserUID = (rawUID) => {
const input = claimValueToString(rawUID);
if (!input) return null;
if (input === '.' || input === '..') return null;
if (!SAFE_UID_PATTERN.test(input)) return null;
return input;
}
function getOIDCIdentityFromClaims(claims, usernameClaim) {
const fallbackClaims = [usernameClaim, 'preferred_username', 'username', 'email', 'sub'];
for (const claimName of fallbackClaims) {
if (!claimName) continue;
const claimValue = parseClaimPath(claims, claimName);
const parsed = claimValueToString(claimValue);
if (parsed) return parsed;
}
return null;
}
exports.createJWTForUser = function(user_uid) {
const payload = {
exp: Math.floor(Date.now() / 1000) + JWT_EXPIRATION,
user: user_uid
};
return jwt.sign(payload, SERVER_SECRET);
}
exports.getAuthResponseObject = async function(user) {
const token = exports.createJWTForUser(user.uid);
return {
user: user,
token: token,
permissions: await exports.userPermissions(user.uid),
available_permissions: CONSTS.AVAILABLE_PERMISSIONS
};
}
exports.upsertOIDCUser = async (claims, options = {}) => {
const username_claim = options.username_claim || 'preferred_username';
const display_name_claim = options.display_name_claim || username_claim;
const groups_claim = options.groups_claim || 'groups';
const admin_claim = options.admin_claim || 'groups';
const admin_value = options.admin_value || 'admin';
const auto_register = options.auto_register !== false;
const oidc_subject = claimValueToString(parseClaimPath(claims, 'sub'));
const login_name = getOIDCIdentityFromClaims(claims, username_claim);
const display_name = claimValueToString(parseClaimPath(claims, display_name_claim)) || login_name;
const uid_to_use = exports.sanitizeUserUID(login_name);
if (!uid_to_use || !display_name) {
logger.error('OIDC login rejected: Could not derive a valid uid/name from OIDC claims.');
return null;
}
const groups = claimToArray(parseClaimPath(claims, groups_claim));
const admin_claim_value = parseClaimPath(claims, admin_claim);
const role = valueIncludes(admin_value, admin_claim_value) ? 'admin' : 'user';
let user_obj = null;
if (oidc_subject) {
user_obj = await db_api.getRecord('users', {oidc_subject: oidc_subject});
}
if (!user_obj) {
user_obj = await db_api.getRecord('users', {uid: uid_to_use});
}
if (!user_obj) {
user_obj = await db_api.getRecord('users', {name: display_name});
}
if (!user_obj) {
if (!auto_register) {
logger.error(`OIDC login rejected: user '${uid_to_use}' does not exist and auto registration is disabled.`);
return null;
}
user_obj = generateUserObject(uid_to_use, display_name, null, 'oidc');
user_obj.role = role;
user_obj.oidc_subject = oidc_subject || null;
user_obj.oidc_groups = groups;
const inserted = await db_api.insertRecordIntoTable('users', user_obj);
if (!inserted) {
logger.error(`OIDC login failed: could not create user '${uid_to_use}'.`);
return null;
}
return await db_api.getRecord('users', {uid: uid_to_use});
}
if (oidc_subject && user_obj.oidc_subject && user_obj.oidc_subject !== oidc_subject) {
logger.error(`OIDC login rejected: existing user '${user_obj.uid}' is mapped to a different subject.`);
return null;
}
const updated_user_values = {
name: display_name,
role: role,
auth_method: 'oidc',
oidc_groups: groups
};
if (oidc_subject) updated_user_values['oidc_subject'] = oidc_subject;
const updated = await db_api.updateRecord('users', {uid: user_obj.uid}, updated_user_values);
if (!updated) {
logger.error(`OIDC login failed: could not update user '${user_obj.uid}'.`);
return null;
}
return await db_api.getRecord('users', {uid: user_obj.uid});
}
exports.deleteUser = async (uid) => {
let success = false;
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
@ -214,21 +363,14 @@ exports.passport.use(new LdapStrategy(getLDAPConfiguration,
* 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);
req.token = exports.createJWTForUser(req.user.uid);
next();
}
exports.returnAuthResponse = async function(req, res) {
res.status(200).json({
user: req.user,
token: req.token,
permissions: await exports.userPermissions(req.user.uid),
available_permissions: CONSTS.AVAILABLE_PERMISSIONS
});
const auth_response = await exports.getAuthResponseObject(req.user);
auth_response.token = req.token;
res.status(200).json(auth_response);
}
/***************************************
@ -311,7 +453,11 @@ exports.getUserVideos = async function(user_uid, type) {
}
exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
let file = await db_api.getRecord('files', {uid: file_uid});
const filter_obj = {uid: file_uid};
if (config_api.getConfigItem('ytdl_multi_user_mode') && user_uid !== null && user_uid !== undefined) {
filter_obj['user_uid'] = user_uid;
}
let file = await db_api.getRecord('files', filter_obj);
// prevent unauthorized users from accessing the file info
if (file && !file['sharingEnabled'] && requireSharing) file = null;
@ -329,7 +475,11 @@ exports.getUserPlaylists = async function(user_uid) {
}
exports.getUserPlaylist = async function(user_uid, playlistID, requireSharing = false) {
let playlist = await db_api.getRecord('playlists', {id: playlistID});
const filter_obj = {id: playlistID};
if (config_api.getConfigItem('ytdl_multi_user_mode') && user_uid !== null && user_uid !== undefined) {
filter_obj['user_uid'] = user_uid;
}
let playlist = await db_api.getRecord('playlists', filter_obj);
// prevent unauthorized users from accessing the file info
if (requireSharing && !playlist['sharingEnabled']) playlist = null;

@ -0,0 +1,211 @@
const { Issuer, generators } = require('openid-client');
const config_api = require('../config');
const logger = require('../logger');
const AUTH_TX_TTL_MS = 10 * 60 * 1000;
const auth_transactions = new Map();
let oidc_issuer = null;
let oidc_client = null;
let initialized = false;
function parseBool(input, fallback = false) {
if (typeof input === 'boolean') return input;
if (typeof input === 'string') {
const normalized = input.trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
}
return fallback;
}
function parseCSV(input) {
if (!input) return [];
if (Array.isArray(input)) return input.map(value => String(value).trim()).filter(value => value.length > 0);
return String(input).split(',').map(value => value.trim()).filter(value => value.length > 0);
}
function normalizeRelativePath(return_to) {
if (!return_to || typeof return_to !== 'string') return '/home';
const trimmed = return_to.trim();
if (!trimmed.startsWith('/')) return '/home';
if (trimmed.startsWith('//')) return '/home';
return trimmed;
}
function cleanupTransactions() {
const now = Date.now();
for (const [state, tx] of auth_transactions.entries()) {
if (!tx || now - tx.created > AUTH_TX_TTL_MS) {
auth_transactions.delete(state);
}
}
}
function getOIDCConfiguration() {
return {
enabled: parseBool(config_api.getConfigItem('ytdl_oidc_enabled'), false),
issuer_url: config_api.getConfigItem('ytdl_oidc_issuer_url'),
client_id: config_api.getConfigItem('ytdl_oidc_client_id'),
client_secret: config_api.getConfigItem('ytdl_oidc_client_secret'),
redirect_uri: config_api.getConfigItem('ytdl_oidc_redirect_uri'),
scope: config_api.getConfigItem('ytdl_oidc_scope') || 'openid profile email',
auto_register: parseBool(config_api.getConfigItem('ytdl_oidc_auto_register'), true),
admin_claim: config_api.getConfigItem('ytdl_oidc_admin_claim') || 'groups',
admin_value: config_api.getConfigItem('ytdl_oidc_admin_value') || 'admin',
groups_claim: config_api.getConfigItem('ytdl_oidc_group_claim') || 'groups',
allowed_groups: parseCSV(config_api.getConfigItem('ytdl_oidc_allowed_groups')),
username_claim: config_api.getConfigItem('ytdl_oidc_username_claim') || 'preferred_username',
display_name_claim: config_api.getConfigItem('ytdl_oidc_display_name_claim') || 'preferred_username'
};
}
function getClaimByPath(claims, claimPath) {
if (!claims || !claimPath || typeof claimPath !== 'string') return undefined;
const pathParts = claimPath.split('.').filter(part => part !== '');
if (pathParts.length === 0) return undefined;
let currentValue = claims;
for (const part of pathParts) {
if (!currentValue || typeof currentValue !== 'object' || !(part in currentValue)) return undefined;
currentValue = currentValue[part];
}
return currentValue;
}
function claimToArray(claimValue) {
if (claimValue === undefined || claimValue === null) return [];
if (Array.isArray(claimValue)) return claimValue.map(value => String(value).trim()).filter(value => value.length > 0);
if (typeof claimValue === 'string' && claimValue.includes(',')) {
return claimValue.split(',').map(value => value.trim()).filter(value => value.length > 0);
}
const normalized = String(claimValue).trim();
return normalized ? [normalized] : [];
}
function ensureOIDCReady() {
if (!initialized || !oidc_client) {
throw new Error('OIDC is not initialized.');
}
}
exports.isEnabled = () => {
return getOIDCConfiguration().enabled;
}
exports.getConfiguration = () => {
return getOIDCConfiguration();
}
exports.initialize = async () => {
const oidc_config = getOIDCConfiguration();
if (!oidc_config.enabled) {
oidc_issuer = null;
oidc_client = null;
initialized = false;
auth_transactions.clear();
return true;
}
if (!oidc_config.issuer_url || !oidc_config.client_id || !oidc_config.client_secret || !oidc_config.redirect_uri) {
throw new Error('OIDC is enabled but one or more required settings are missing (issuer_url, client_id, client_secret, redirect_uri).');
}
const discovered_issuer = await Issuer.discover(String(oidc_config.issuer_url).trim());
oidc_issuer = discovered_issuer;
oidc_client = new oidc_issuer.Client({
client_id: String(oidc_config.client_id).trim(),
client_secret: String(oidc_config.client_secret).trim(),
redirect_uris: [String(oidc_config.redirect_uri).trim()],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_post'
});
initialized = true;
logger.info('OIDC authentication initialized successfully.');
return true;
}
exports.getStatus = () => {
const oidc_config = getOIDCConfiguration();
return {
enabled: oidc_config.enabled,
initialized: initialized && !!oidc_client,
auto_register: oidc_config.auto_register
};
}
exports.createAuthorizationURL = (return_to = '/home') => {
ensureOIDCReady();
cleanupTransactions();
const oidc_config = getOIDCConfiguration();
const normalized_return_to = normalizeRelativePath(return_to);
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const state = generators.state();
const nonce = generators.nonce();
auth_transactions.set(state, {
code_verifier: code_verifier,
nonce: nonce,
return_to: normalized_return_to,
created: Date.now()
});
return oidc_client.authorizationUrl({
scope: oidc_config.scope,
code_challenge: code_challenge,
code_challenge_method: 'S256',
response_type: 'code',
state: state,
nonce: nonce
});
}
exports.consumeAuthorizationCallback = async (req) => {
ensureOIDCReady();
cleanupTransactions();
const params = oidc_client.callbackParams(req);
const state = params.state;
if (!state || !auth_transactions.has(state)) {
throw new Error('OIDC callback rejected: missing or invalid state.');
}
const tx = auth_transactions.get(state);
auth_transactions.delete(state);
const oidc_config = getOIDCConfiguration();
const redirect_uri = String(oidc_config.redirect_uri).trim();
const token_set = await oidc_client.callback(redirect_uri, params, {
state: state,
nonce: tx.nonce,
code_verifier: tx.code_verifier
});
const id_claims = token_set.claims() || {};
let userinfo_claims = {};
if (token_set.access_token) {
try {
userinfo_claims = await oidc_client.userinfo(token_set.access_token);
} catch (err) {
logger.warn(`OIDC userinfo call failed, falling back to ID token claims. ${err.message}`);
}
}
return {
claims: Object.assign({}, userinfo_claims || {}, id_claims || {}),
return_to: tx.return_to || '/home'
};
}
exports.isClaimsAllowed = (claims) => {
const oidc_config = getOIDCConfiguration();
const allowed_groups = oidc_config.allowed_groups || [];
if (!allowed_groups.length) return true;
const groups_value = getClaimByPath(claims, oidc_config.groups_claim || 'groups');
const user_groups = claimToArray(groups_value).map(group => group.toLowerCase());
return allowed_groups.some(group => user_groups.includes(String(group).toLowerCase()));
}

@ -264,6 +264,21 @@ const DEFAULT_CONFIG = {
"bindCredentials": "secret",
"searchBase": "ou=passport-ldapauth",
"searchFilter": "(uid={{username}})"
},
"oidc": {
"enabled": false,
"issuer_url": "",
"client_id": "",
"client_secret": "",
"redirect_uri": "",
"scope": "openid profile email",
"auto_register": true,
"admin_claim": "groups",
"admin_value": "admin",
"group_claim": "groups",
"allowed_groups": "",
"username_claim": "preferred_username",
"display_name_claim": "preferred_username"
}
},
"Database": {

@ -229,6 +229,58 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_ldap_config',
'path': 'YoutubeDLMaterial.Users.ldap_config'
},
'ytdl_oidc_enabled': {
'key': 'ytdl_oidc_enabled',
'path': 'YoutubeDLMaterial.Users.oidc.enabled'
},
'ytdl_oidc_issuer_url': {
'key': 'ytdl_oidc_issuer_url',
'path': 'YoutubeDLMaterial.Users.oidc.issuer_url'
},
'ytdl_oidc_client_id': {
'key': 'ytdl_oidc_client_id',
'path': 'YoutubeDLMaterial.Users.oidc.client_id'
},
'ytdl_oidc_client_secret': {
'key': 'ytdl_oidc_client_secret',
'path': 'YoutubeDLMaterial.Users.oidc.client_secret'
},
'ytdl_oidc_redirect_uri': {
'key': 'ytdl_oidc_redirect_uri',
'path': 'YoutubeDLMaterial.Users.oidc.redirect_uri'
},
'ytdl_oidc_scope': {
'key': 'ytdl_oidc_scope',
'path': 'YoutubeDLMaterial.Users.oidc.scope'
},
'ytdl_oidc_auto_register': {
'key': 'ytdl_oidc_auto_register',
'path': 'YoutubeDLMaterial.Users.oidc.auto_register'
},
'ytdl_oidc_admin_claim': {
'key': 'ytdl_oidc_admin_claim',
'path': 'YoutubeDLMaterial.Users.oidc.admin_claim'
},
'ytdl_oidc_admin_value': {
'key': 'ytdl_oidc_admin_value',
'path': 'YoutubeDLMaterial.Users.oidc.admin_value'
},
'ytdl_oidc_group_claim': {
'key': 'ytdl_oidc_group_claim',
'path': 'YoutubeDLMaterial.Users.oidc.group_claim'
},
'ytdl_oidc_allowed_groups': {
'key': 'ytdl_oidc_allowed_groups',
'path': 'YoutubeDLMaterial.Users.oidc.allowed_groups'
},
'ytdl_oidc_username_claim': {
'key': 'ytdl_oidc_username_claim',
'path': 'YoutubeDLMaterial.Users.oidc.username_claim'
},
'ytdl_oidc_display_name_claim': {
'key': 'ytdl_oidc_display_name_claim',
'path': 'YoutubeDLMaterial.Users.oidc.display_name_claim'
},
// Database
'ytdl_use_local_db': {

@ -8,6 +8,10 @@ const archive_api = require('./archive');
const utils = require('./utils')
const logger = require('./logger');
function shouldRestrictToUser(user_uid) {
return config_api.getConfigItem('ytdl_multi_user_mode') && user_uid !== null && user_uid !== undefined;
}
exports.registerFileDB = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => {
if (!file_object) file_object = generateFileObject(file_path, type);
if (!file_object) {
@ -137,7 +141,8 @@ exports.addMetadataPropertyToDB = async (property_key) => {
}
exports.createPlaylist = async (playlist_name, uids, user_uid = null) => {
const first_video = await exports.getVideo(uids[0]);
const first_video = await exports.getVideo(uids[0], user_uid);
if (!first_video) return null;
const thumbnailToUse = first_video['thumbnailURL'];
let new_playlist = {
@ -160,12 +165,16 @@ exports.createPlaylist = async (playlist_name, uids, user_uid = null) => {
}
exports.getPlaylist = async (playlist_id, user_uid = null, require_sharing = false) => {
let playlist = await db_api.getRecord('playlists', {id: playlist_id});
const playlist_filter = {id: playlist_id};
if (shouldRestrictToUser(user_uid)) playlist_filter['user_uid'] = user_uid;
let playlist = await db_api.getRecord('playlists', playlist_filter);
if (!playlist) {
playlist = await db_api.getRecord('categories', {uid: playlist_id});
if (playlist) {
const uids = (await db_api.getRecords('files', {'category.uid': playlist_id})).map(file => file.uid);
const files_filter = {'category.uid': playlist_id};
if (shouldRestrictToUser(user_uid)) files_filter['user_uid'] = user_uid;
const uids = (await db_api.getRecords('files', files_filter)).map(file => file.uid);
playlist['uids'] = uids;
playlist['auto'] = true;
}
@ -190,17 +199,21 @@ exports.getPlaylist = async (playlist_id, user_uid = null, require_sharing = fal
return playlist;
}
exports.updatePlaylist = async (playlist) => {
exports.updatePlaylist = async (playlist, user_uid = null) => {
let playlistID = playlist.id;
const filter_obj = {id: playlistID};
if (shouldRestrictToUser(user_uid)) filter_obj['user_uid'] = user_uid;
const duration = await exports.calculatePlaylistDuration(playlist);
playlist.duration = duration;
return await db_api.updateRecord('playlists', {id: playlistID}, playlist);
return await db_api.updateRecord('playlists', filter_obj, playlist);
}
exports.setPlaylistProperty = async (playlist_id, assignment_obj, user_uid = null) => {
let success = await db_api.updateRecord('playlists', {id: playlist_id}, assignment_obj);
const playlist_filter_obj = {id: playlist_id};
if (shouldRestrictToUser(user_uid)) playlist_filter_obj['user_uid'] = user_uid;
let success = await db_api.updateRecord('playlists', playlist_filter_obj, assignment_obj);
if (!success) {
success = await db_api.updateRecord('categories', {uid: playlist_id}, assignment_obj);
@ -221,7 +234,7 @@ exports.calculatePlaylistDuration = async (playlist, playlist_file_objs = null)
const playlist_uids_to_scan = playlist_uids.slice(0, max_playlist_uids_to_scan);
for (let i = 0; i < playlist_uids_to_scan.length; i++) {
const uid = playlist_uids_to_scan[i];
const file_obj = await exports.getVideo(uid);
const file_obj = await exports.getVideo(uid, playlist.user_uid);
if (file_obj) playlist_file_objs.push(file_obj);
}
}
@ -229,8 +242,9 @@ exports.calculatePlaylistDuration = async (playlist, playlist_file_objs = null)
return playlist_file_objs.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
}
exports.deleteFile = async (uid, blacklistMode = false) => {
const file_obj = await exports.getVideo(uid);
exports.deleteFile = async (uid, blacklistMode = false, user_uid = null) => {
const file_obj = await exports.getVideo(uid, user_uid);
if (!file_obj) return false;
const type = file_obj.isAudio ? 'audio' : 'video';
const folderPath = path.dirname(file_obj.path);
const name = file_obj.id;
@ -316,16 +330,24 @@ exports.deleteFile = async (uid, blacklistMode = false) => {
// Video ID is basically just the file name without the base path and file extension - this method helps us get away from that
exports.getVideoUIDByID = async (file_id, uuid = null) => {
const file_obj = await db_api.getRecord('files', {id: file_id});
const filter_obj = {id: file_id};
if (shouldRestrictToUser(uuid)) filter_obj['user_uid'] = uuid;
const file_obj = await db_api.getRecord('files', filter_obj);
return file_obj ? file_obj['uid'] : null;
}
exports.getVideo = async (file_uid) => {
return await db_api.getRecord('files', {uid: file_uid});
exports.getVideo = async (file_uid, user_uid = null, sub_id = null) => {
const filter_obj = {uid: file_uid};
if (shouldRestrictToUser(user_uid)) filter_obj['user_uid'] = user_uid;
if (sub_id) filter_obj['sub_id'] = sub_id;
return await db_api.getRecord('files', filter_obj);
}
exports.getAllFiles = async (sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid) => {
const filter_obj = {user_uid: uuid};
const filter_obj = {};
if (config_api.getConfigItem('ytdl_multi_user_mode')) {
filter_obj['user_uid'] = uuid;
}
const regex = true;
if (text_search) {
if (regex) {

@ -38,6 +38,7 @@
"multer": "^2.0.2",
"node-id3": "^0.2.9",
"node-schedule": "^2.1.1",
"openid-client": "^5.7.1",
"passport": "^0.6.0",
"passport-http": "^0.3.0",
"passport-jwt": "^4.0.1",
@ -2430,6 +2431,15 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@ -3232,6 +3242,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -3244,6 +3263,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"license": "MIT",
@ -3290,6 +3318,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/p-cancelable": {
"version": "2.1.1",
"license": "MIT",

@ -52,6 +52,7 @@
"multer": "^2.0.2",
"node-id3": "^0.2.9",
"node-schedule": "^2.1.1",
"openid-client": "^5.7.1",
"passport": "^0.6.0",
"passport-http": "^0.3.0",
"passport-jwt": "^4.0.1",

@ -13,6 +13,10 @@ const debugMode = process.env.YTDL_MODE === 'debug';
const db_api = require('./db');
const downloader_api = require('./downloader');
function shouldRestrictToUser(user_uid) {
return config_api.getConfigItem('ytdl_multi_user_mode') && user_uid !== null && user_uid !== undefined;
}
exports.subscribe = async (sub, user_uid = null, skip_get_info = false) => {
const result_obj = {
success: false,
@ -100,7 +104,13 @@ async function getSubscriptionInfo(sub) {
}
exports.unsubscribe = async (sub_id, deleteMode, user_uid = null) => {
const sub = await exports.getSubscription(sub_id);
const sub = await exports.getSubscription(sub_id, user_uid);
if (!sub) {
return {
success: false,
error: 'Subscription not found or not owned by the current user.'
};
}
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
@ -124,12 +134,14 @@ exports.unsubscribe = async (sub_id, deleteMode, user_uid = null) => {
}
await killSubDownloads(sub_id, true);
await db_api.removeRecord('subscriptions', {id: id});
await db_api.removeAllRecords('files', {sub_id: id});
const remove_sub_filter = {id: id};
if (shouldRestrictToUser(user_uid)) remove_sub_filter['user_uid'] = user_uid;
await db_api.removeRecord('subscriptions', remove_sub_filter);
await db_api.removeAllRecords('files', {sub_id: id, ...(shouldRestrictToUser(user_uid) ? {user_uid: user_uid} : {})});
// failed subs have no name, on unsubscribe they shouldn't error
if (!sub.name) {
return;
return {success: true};
}
const appendedBasePath = getAppendedBasePath(sub, basePath);
@ -137,7 +149,8 @@ exports.unsubscribe = async (sub_id, deleteMode, user_uid = null) => {
await fs.remove(appendedBasePath);
}
await db_api.removeAllRecords('archives', {sub_id: sub.id});
await db_api.removeAllRecords('archives', {sub_id: sub.id, ...(shouldRestrictToUser(user_uid) ? {user_uid: user_uid} : {})});
return {success: true};
}
exports.deleteSubscriptionFile = async (sub, file, deleteForever, file_uid = null, user_uid = null) => {
@ -266,8 +279,8 @@ async function getValidSubscriptionsToCheck() {
return valid_subscription_ids;
}
exports.getVideosForSub = async (sub_id) => {
const sub = await exports.getSubscription(sub_id);
exports.getVideosForSub = async (sub_id, user_uid = null) => {
const sub = await exports.getSubscription(sub_id, user_uid);
if (!sub || sub['downloading']) {
return false;
}
@ -468,8 +481,12 @@ async function getFilesToDownload(sub, output_jsons) {
return files_to_download;
}
exports.cancelCheckSubscription = async (sub_id) => {
const sub = await exports.getSubscription(sub_id);
exports.cancelCheckSubscription = async (sub_id, user_uid = null) => {
const sub = await exports.getSubscription(sub_id, user_uid);
if (!sub) {
logger.error('Failed to cancel subscription check, subscription not found.');
return false;
}
if (!sub['downloading'] && !sub['child_process']) {
logger.error('Failed to cancel subscription check, verify that it is still running!');
return false;
@ -499,18 +516,26 @@ async function killSubDownloads(sub_id, remove_downloads = false) {
exports.getSubscriptions = async (user_uid = null) => {
// TODO: fix issue where the downloading property may not match getSubscription()
if (!config_api.getConfigItem('ytdl_multi_user_mode')) {
return await db_api.getRecords('subscriptions');
}
return await db_api.getRecords('subscriptions', {user_uid: user_uid});
}
exports.getAllSubscriptions = async () => {
const all_subs = await db_api.getRecords('subscriptions');
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode);
if (!multiUserMode) return all_subs;
return all_subs.filter(sub => !!(sub.user_uid));
}
exports.getSubscription = async (subID) => {
exports.getSubscription = async (subID, user_uid = null) => {
// stringify and parse because we may override the 'downloading' property
const sub = JSON.parse(JSON.stringify(await db_api.getRecord('subscriptions', {id: subID})));
const filter_obj = {id: subID};
if (shouldRestrictToUser(user_uid)) filter_obj['user_uid'] = user_uid;
const raw_sub = await db_api.getRecord('subscriptions', filter_obj);
if (!raw_sub) return null;
const sub = JSON.parse(JSON.stringify(raw_sub));
// now with the download_queue, we may need to override 'downloading'
const current_downloads = await db_api.getRecords('download_queue', {running: true, sub_id: subID}, true);
if (!sub['downloading']) sub['downloading'] = current_downloads > 0;
@ -518,11 +543,17 @@ exports.getSubscription = async (subID) => {
}
exports.getSubscriptionByName = async (subName, user_uid = null) => {
if (!config_api.getConfigItem('ytdl_multi_user_mode')) {
return await db_api.getRecord('subscriptions', {name: subName});
}
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
}
exports.updateSubscription = async (sub) => {
await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
exports.updateSubscription = async (sub, user_uid = null) => {
const filter_obj = {id: sub.id};
if (shouldRestrictToUser(user_uid)) filter_obj['user_uid'] = user_uid;
const updated = await db_api.updateRecord('subscriptions', filter_obj, sub);
if (!updated) return false;
exports.writeSubscriptionMetadata(sub);
return true;
}

@ -70,8 +70,9 @@ export class AppComponent implements OnInit, AfterViewInit {
}
ngOnInit(): void {
if (localStorage.getItem('theme')) {
this.setTheme(localStorage.getItem('theme'));
const storedTheme = this.getStoredTheme();
if (storedTheme) {
this.setTheme(storedTheme);
}
this.postsService.open_create_default_admin_dialog.subscribe(open => {
@ -106,8 +107,11 @@ export class AppComponent implements OnInit, AfterViewInit {
this.enableDownloadsManager = this.postsService.config['Extra']['enable_downloads_manager'];
// sets theme to config default if it doesn't exist
if (!localStorage.getItem('theme')) {
const storedTheme = this.getStoredTheme();
if (!storedTheme) {
this.setTheme(themingExists ? this.defaultTheme : 'default');
} else {
this.setTheme(storedTheme);
}
// gets the subscriptions
@ -124,24 +128,44 @@ export class AppComponent implements OnInit, AfterViewInit {
// theme stuff
getThemeStorageKey(): string {
if (this.postsService.config && this.postsService.config['Advanced'] && this.postsService.config['Advanced']['multi_user_mode'] && this.postsService.user && this.postsService.user.uid) {
return `theme_${this.postsService.user.uid}`;
}
return 'theme';
}
getStoredTheme(): string {
return localStorage.getItem(this.getThemeStorageKey()) || localStorage.getItem('theme');
}
setStoredTheme(theme: string): void {
const key = this.getThemeStorageKey();
localStorage.setItem(key, theme);
if (key !== 'theme') {
localStorage.removeItem('theme');
}
}
setTheme(theme) {
// theme is registered, so set it to the stored cookie variable
let old_theme = null;
if (this.THEMES_CONFIG[theme]) {
if (localStorage.getItem('theme')) {
old_theme = localStorage.getItem('theme');
const currentTheme = this.getStoredTheme();
if (currentTheme) {
old_theme = currentTheme;
if (!this.THEMES_CONFIG[old_theme]) {
console.log('bad theme found, setting to default');
if (this.defaultTheme === null) {
// means it hasn't loaded yet
console.error('No default theme detected');
} else {
localStorage.setItem('theme', this.defaultTheme);
old_theme = localStorage.getItem('theme'); // updates old_theme
this.setStoredTheme(this.defaultTheme);
old_theme = this.getStoredTheme(); // updates old_theme
}
}
}
localStorage.setItem('theme', theme);
this.setStoredTheme(theme);
this.elementRef.nativeElement.ownerDocument.body.style.backgroundColor = this.THEMES_CONFIG[theme]['background_color'];
} else {
console.error('Invalid theme: ' + theme);

@ -1,56 +1,68 @@
<mat-card class="login-card">
<mat-tab-group mat-stretch-tabs style="margin-bottom: 20px" [(selectedIndex)]="selectedTabIndex">
<mat-tab label="Login" i18n-label="Login">
<div style="margin-top: 10px;">
<mat-form-field style="width: 100%">
<mat-label i18n="User name">User name</mat-label>
<input [(ngModel)]="loginUsernameInput" matInput>
</mat-form-field>
@if (oidcEnabled) {
<div style="padding: 16px 0;">
<p i18n="OIDC login redirecting text">Redirecting to your identity provider...</p>
<mat-progress-bar class="login-progress-bar" mode="indeterminate"></mat-progress-bar>
<div class="login-button-div">
<button [disabled]="oidcRedirecting" color="primary" (click)="redirectToOIDC()" mat-raised-button>
<ng-container i18n="OIDC login continue button">Continue</ng-container>
</button>
</div>
<div>
<mat-form-field style="width: 100%">
<mat-label i18n="Password">Password</mat-label>
<input [(ngModel)]="loginPasswordInput" (keyup.enter)="login()" type="password" matInput>
</mat-form-field>
</div>
</mat-tab>
@if (registrationEnabled) {
<mat-tab label="Register" i18n-label="Register">
</div>
} @else {
<mat-tab-group mat-stretch-tabs style="margin-bottom: 20px" [(selectedIndex)]="selectedTabIndex">
<mat-tab label="Login" i18n-label="Login">
<div style="margin-top: 10px;">
<mat-form-field style="width: 100%">
<mat-label i18n="User name">User name</mat-label>
<input [(ngModel)]="registrationUsernameInput" matInput>
<input [(ngModel)]="loginUsernameInput" matInput>
</mat-form-field>
</div>
<div>
<mat-form-field style="width: 100%">
<mat-label i18n="Password">Password</mat-label>
<input [(ngModel)]="registrationPasswordInput" (click)="register()" type="password" matInput>
</mat-form-field>
</div>
<div>
<mat-form-field style="width: 100%">
<mat-label i18n="Confirm Password">Confirm Password</mat-label>
<input [(ngModel)]="registrationPasswordConfirmationInput" (click)="register()" type="password" matInput>
<input [(ngModel)]="loginPasswordInput" (keyup.enter)="login()" type="password" matInput>
</mat-form-field>
</div>
</mat-tab>
}
</mat-tab-group>
@if (selectedTabIndex === 0) {
<div class="login-button-div">
<button [disabled]="loggingIn" color="primary" (click)="login()" mat-raised-button><ng-container i18n="Login">Login</ng-container></button>
@if (loggingIn) {
<mat-progress-bar class="login-progress-bar" mode="indeterminate"></mat-progress-bar>
@if (registrationEnabled) {
<mat-tab label="Register" i18n-label="Register">
<div style="margin-top: 10px;">
<mat-form-field style="width: 100%">
<mat-label i18n="User name">User name</mat-label>
<input [(ngModel)]="registrationUsernameInput" matInput>
</mat-form-field>
</div>
<div>
<mat-form-field style="width: 100%">
<mat-label i18n="Password">Password</mat-label>
<input [(ngModel)]="registrationPasswordInput" (click)="register()" type="password" matInput>
</mat-form-field>
</div>
<div>
<mat-form-field style="width: 100%">
<mat-label i18n="Confirm Password">Confirm Password</mat-label>
<input [(ngModel)]="registrationPasswordConfirmationInput" (click)="register()" type="password" matInput>
</mat-form-field>
</div>
</mat-tab>
}
</div>
}
@if (selectedTabIndex === 1) {
<div class="login-button-div">
<button [disabled]="registering" color="primary" (click)="register()" mat-raised-button><ng-container i18n="Register">Register</ng-container></button>
@if (registering) {
<mat-progress-bar class="login-progress-bar" mode="indeterminate"></mat-progress-bar>
}
</div>
</mat-tab-group>
@if (selectedTabIndex === 0) {
<div class="login-button-div">
<button [disabled]="loggingIn" color="primary" (click)="login()" mat-raised-button><ng-container i18n="Login">Login</ng-container></button>
@if (loggingIn) {
<mat-progress-bar class="login-progress-bar" mode="indeterminate"></mat-progress-bar>
}
</div>
}
@if (selectedTabIndex === 1) {
<div class="login-button-div">
<button [disabled]="registering" color="primary" (click)="register()" mat-raised-button><ng-container i18n="Register">Register</ng-container></button>
@if (registering) {
<mat-progress-bar class="login-progress-bar" mode="indeterminate"></mat-progress-bar>
}
</div>
}
}
</mat-card>
</mat-card>

@ -1,6 +1,7 @@
import { Component, OnInit } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { Router } from '@angular/router';
import { filter, take } from 'rxjs/operators';
@ -25,10 +26,24 @@ export class LoginComponent implements OnInit {
registrationPasswordInput = '';
registrationPasswordConfirmationInput = '';
registering = false;
oidcEnabled = false;
oidcRedirecting = false;
returnTo = '/home';
constructor(private postsService: PostsService, private snackBar: MatSnackBar, private router: Router) { }
constructor(private postsService: PostsService, private snackBar: MatSnackBar, private router: Router, private route: ActivatedRoute) { }
ngOnInit(): void {
const routeReturnTo = this.route.snapshot.queryParamMap.get('returnTo');
this.returnTo = routeReturnTo && routeReturnTo.startsWith('/') ? routeReturnTo : '/home';
const oidcToken = this.route.snapshot.paramMap.get('oidc_token');
const oidcRedirectPath = this.route.snapshot.paramMap.get('redirect');
if (oidcToken) {
const redirectPath = oidcRedirectPath && oidcRedirectPath.startsWith('/') ? oidcRedirectPath : this.returnTo;
this.postsService.completeOIDCLogin(oidcToken, redirectPath);
return;
}
if (this.postsService.isLoggedIn && localStorage.getItem('jwt_token') !== 'null') {
this.router.navigate(['/home']);
}
@ -37,6 +52,12 @@ export class LoginComponent implements OnInit {
.subscribe(() => {
if (!this.postsService.config['Advanced']['multi_user_mode']) {
this.router.navigate(['/home']);
return;
}
this.oidcEnabled = this.postsService.isOIDCEnabled();
if (this.oidcEnabled) {
this.redirectToOIDC();
return;
}
this.registrationEnabled = this.postsService.config['Users'] && this.postsService.config['Users']['allow_registration'];
});
@ -50,7 +71,7 @@ export class LoginComponent implements OnInit {
this.postsService.login(this.loginUsernameInput, this.loginPasswordInput).subscribe(res => {
this.loggingIn = false;
if (res['token']) {
this.postsService.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions']);
this.postsService.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions'], this.returnTo);
} else {
this.openSnackBar('Login failed, unknown error.');
}
@ -114,4 +135,12 @@ export class LoginComponent implements OnInit {
});
}
redirectToOIDC() {
if (this.oidcRedirecting) {
return;
}
this.oidcRedirecting = true;
window.location.href = this.postsService.getOIDCLoginURL(this.returnTo || '/home');
}
}

@ -195,7 +195,9 @@ export class PostsService {
this.config = result['YoutubeDLMaterial'];
this.titleService.setTitle(this.config['Extra']['title_top']);
if (this.config['Advanced']['multi_user_mode']) {
this.checkAdminCreationStatus();
if (!this.isOIDCEnabled()) {
this.checkAdminCreationStatus();
}
// login stuff
if (localStorage.getItem('jwt_token') && localStorage.getItem('jwt_token') !== 'null') {
this.token = localStorage.getItem('jwt_token');
@ -720,7 +722,7 @@ export class PostsService {
return this.http.get('https://api.github.com/repos/voc0der/youtubedl-material/releases');
}
afterLogin(user, token, permissions, available_permissions) {
afterLogin(user, token, permissions, available_permissions, redirect_path = '/home') {
this.isLoggedIn = true;
this.user = user;
this.permissions = permissions;
@ -734,8 +736,8 @@ export class PostsService {
// needed to re-initialize parts of app after login
this.config_reloaded.next(true);
if (this.router.url === '/login') {
this.router.navigate(['/home']);
if (this.router.url.startsWith('/login')) {
this.router.navigateByUrl(redirect_path || '/home');
}
}
@ -745,6 +747,30 @@ export class PostsService {
return this.http.post<LoginResponse>(this.path + 'auth/login', body, this.httpOptions);
}
completeOIDCLogin(token: string, redirect_path = '/home') {
this.token = token;
this.httpOptions.params = this.httpOptions.params.set('jwt', this.token);
const call = this.http.post<LoginResponse>(this.path + 'auth/jwtAuth', {}, this.httpOptions);
call.subscribe(res => {
if (res['token']) {
this.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions'], redirect_path);
}
}, () => {
this.sendToLogin();
this.token = null;
this.resetHttpParams();
});
return call;
}
isOIDCEnabled(): boolean {
return !!(this.config && this.config['Users'] && this.config['Users']['oidc'] && this.config['Users']['oidc']['enabled']);
}
getOIDCLoginURL(return_to = '/home'): string {
return `${this.path}auth/oidc/login?returnTo=${encodeURIComponent(return_to)}`;
}
// user methods
jwtAuth() {
const call = this.http.post(this.path + 'auth/jwtAuth', {}, this.httpOptions);
@ -769,7 +795,7 @@ export class PostsService {
this.isLoggedIn = false;
this.token = null;
localStorage.setItem('jwt_token', null);
if (this.router.url !== '/login') {
if (!this.router.url.startsWith('/login')) {
this.router.navigate(['/login']);
}
@ -795,11 +821,16 @@ export class PostsService {
if (!this.initialized) {
this.setInitialized();
}
if (this.router.url === '/login') {
if (this.router.url.startsWith('/login')) {
return;
}
this.router.navigate(['/login']);
const return_to = this.router.url && this.router.url !== '/login' ? this.router.url : '/home';
this.router.navigate(['/login'], {queryParams: {returnTo: return_to}});
if (this.isOIDCEnabled()) {
return;
}
// send login notification
this.openSnackBar('You must log in to access this page!');
@ -843,6 +874,9 @@ export class PostsService {
}
checkAdminCreationStatus(force_show = false) {
if (this.isOIDCEnabled()) {
return;
}
if (!force_show && !this.config['Advanced']['multi_user_mode']) {
return;
}

@ -59,6 +59,21 @@
"bindCredentials": "secret",
"searchBase": "ou=passport-ldapauth",
"searchFilter": "(uid={{username}})"
},
"oidc": {
"enabled": false,
"issuer_url": "",
"client_id": "",
"client_secret": "",
"redirect_uri": "",
"scope": "openid profile email",
"auto_register": true,
"admin_claim": "groups",
"admin_value": "admin",
"group_claim": "groups",
"allowed_groups": "",
"username_claim": "preferred_username",
"display_name_claim": "preferred_username"
}
},
"Database": {
@ -76,4 +91,4 @@
"default_downloader": "youtube-dl"
}
}
}
}

Loading…
Cancel
Save