Skip to content
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,12 @@ trade than a job on every PR. Update the badge in the same PR as whatever moved
from `backend/test/helpers/app-process.js` and send it real requests. It runs in a child
process with its own working directory, config and media folders, so nothing it writes
outlives the test, and c8 follows the child, so `app.js` counts toward coverage.
- When a test needs `app.js` to meet a failure a real server cannot easily be made to have --
a database write that throws, a provider that is down -- `createAppHarness()` from
`backend/test/helpers/app-harness.js` loads all of `app.js` in a `vm` context, with the
modules the test names swapped for stand-ins and nothing started. `createRouteHarness()` in
`helpers/app-routes.js` builds on it to call a route's handler directly. Neither runs the
middleware in front of a route, so authentication and permissions stay with `startApp()`.
- There is no MongoDB in CI, so backend tests that need one use the in-memory stand-in in
`backend/test/helpers/fake-mongo.js`: `useFakeMongo()` points `db.js` at it for a describe
block, and `helpers/fake-mongo-server.js` does the same for a `startApp()` server when passed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
[![Docker pulls badge](https://img.shields.io/docker/pulls/voc0der/ytdl-material.svg)](https://hub.docker.com/r/voc0der/ytdl-material)
[![Docker image size badge](https://img.shields.io/docker/image-size/voc0der/ytdl-material?sort=date)](https://hub.docker.com/r/voc0der/ytdl-material)
<a href="https://github.com/voc0der/ytdl-material/blob/main/CONTRIBUTING.md#coverage">
<img src="https://img.shields.io/badge/coverage-91.4%25-brightgreen" alt="Code coverage percentage" />
<img src="https://img.shields.io/badge/coverage-95.6%25-brightgreen" alt="Code coverage percentage" />
</a>
[![GitHub issues badge](https://img.shields.io/github/issues/voc0der/ytdl-material)](https://github.com/voc0der/ytdl-material/issues)
[![License badge](https://img.shields.io/github/license/voc0der/ytdl-material)](https://github.com/voc0der/ytdl-material/blob/main/LICENSE.md)
Expand Down
65 changes: 0 additions & 65 deletions backend/authentication/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -532,31 +532,6 @@ exports.returnAuthResponse = async function(req, res) {
res.status(200).json(auth_response);
}

/***************************************
* 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 = (req, res, next) => {
const token = getToken(req.query);
if( token ) {
try {
const 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 {
res.status(401).send('Invalid Authentication');
}
} else {
res.status(401).send('Missing Authorization header');
}
}

/*************************************************
* Confirms a password against the stored hash for
* one account. Used to make a password change prove
Expand Down Expand Up @@ -620,11 +595,6 @@ exports.adminExists = async function() {

// video stuff

exports.getUserVideos = async function(user_uid, type) {
const files = await db_api.getRecords('files', {user_uid: user_uid});
return type ? files.filter(file => file.isAudio === (type === 'audio')) : files;
}

exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
const filter_obj = {uid: file_uid};
if (config_api.getConfigItem('ytdl_multi_user_mode') && user_uid !== null && user_uid !== undefined) {
Expand All @@ -638,28 +608,6 @@ exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false
return file;
}

exports.removePlaylist = async function(user_uid, playlistID) {
await db_api.removeRecord('playlist', {playlistID: playlistID});
return true;
}

exports.getUserPlaylists = async function(user_uid) {
return await db_api.getRecords('playlists', {user_uid: user_uid});
}

exports.getUserPlaylist = async function(user_uid, playlistID, requireSharing = false) {
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 || !playlist['sharingEnabled'])) playlist = null;

return playlist;
}

/*************************************************
* The caller's uid was taken as an argument and
* then ignored: sharing was toggled by object id
Expand Down Expand Up @@ -780,19 +728,6 @@ exports.userPermissions = async function(user_uid) {
return CONSTS.AVAILABLE_PERMISSIONS.filter(permission => resolvePermission(user_obj, role_permissions, permission));
}

function getToken(queryParams) {
if (queryParams && queryParams.jwt) {
const 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,
Expand Down
7 changes: 0 additions & 7 deletions backend/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,13 +274,6 @@ exports.setConfigItems = (items) => {
return success;
}

exports.globalArgsRequiresSafeDownload = () => {
const globalArgs = exports.getConfigItem('ytdl_custom_args').split(',,');
const argsThatRequireSafeDownload = ['--write-sub', '--write-srt', '--proxy'];
const failedArgs = globalArgs.filter(arg => argsThatRequireSafeDownload.includes(arg));
return failedArgs && failedArgs.length > 0;
}

exports.findChangedConfigItems = (old_config, new_config, path = '', changedConfigItems = [], depth = 0) => {
if (typeof old_config === 'object' && typeof new_config === 'object' && depth < 3) {
for (const key in old_config) {
Expand Down
97 changes: 97 additions & 0 deletions backend/test/app-error-responses.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
const assert = require('assert');
const { createRouteHarness } = require('./helpers/app-routes');
const plain = value => JSON.parse(JSON.stringify(value));

describe('Server error responses', function() {
let harness;
afterEach(function() { if (harness) harness.close(); });

it('accepts URLs only from the configured webhook chat and an existing requested owner', async function() {
const downloads = [];
const notifications = [];
harness = createRouteHarness({ config: {
ytdl_use_telegram_API: true, ytdl_telegram_webhook_secret: 'secret', ytdl_telegram_chat_id: '123'
}, dependencies: {
'./db': { initialize() {}, getRecord: async (table, filter) => filter.uid === 'alice' ? { uid: 'alice' } : null },
'./downloader': { createDownload: (...args) => downloads.push(args) },
'./notifications': { sendTelegramNotification: payload => notifications.push(payload) }
}});
const request = (text, owner = 'alice', chat = 123) => harness.request('post', '/api/telegramRequest', {
get: () => 'secret', body: { message: { text, chat: { id: chat } } }, query: { user_uid: owner }
});
assert.strictEqual((await request('Please download https://example.com/first and https://example.com/second')).res.statusCode, 200);
assert.deepStrictEqual(plain(downloads), [['https://example.com/first', 'video', {}, 'alice']]);
assert.strictEqual((await request('https://example.com/clip', 'unknown')).res.statusCode, 400);
assert.strictEqual((await request('https://example.com/clip', 'alice', 999)).res.statusCode, 403);
assert.strictEqual(downloads.length, 1);
assert.strictEqual((await request('not a link')).res.statusCode, 400);
assert.strictEqual(notifications.length, 1);
assert.strictEqual(notifications[0].title, 'Invalid Telegram Request');
assert.strictEqual((await request('https://example.com/clip', null)).res.statusCode, 200);
assert.strictEqual(downloads[1][3], null);
});

it('limits active snips, records thrown failures and hides jobs from another owner', async function() {
const jobs = [];
harness = createRouteHarness({ dependencies: { './files': { snipFile: (uid, start, end, owner, progress) =>
new Promise((resolve, reject) => jobs.push({ resolve, reject, progress, owner })) } } });
const start = () => harness.request('post', '/api/snipFile', { user: { uid: 'alice' }, body: { uid: 'clip', start: 1, end: 3 } });
const started = await Promise.all([start(), start(), start()]);
assert.strictEqual((await start()).res.body.success, false);
assert.strictEqual(jobs.length, 3);
jobs[0].progress(45);
const job_uid = started[0].res.body.job_uid;
let status = await harness.request('post', '/api/getSnipStatus', { user: { uid: 'alice' }, body: { job_uid } });
assert.strictEqual(status.res.body.percent, 45);
assert.strictEqual((await harness.request('post', '/api/getSnipStatus', { user: { uid: 'bob' }, body: { job_uid } })).res.body.success, false);
jobs[0].reject(new Error('encoder crashed'));
await new Promise(resolve => setImmediate(resolve));
status = await harness.request('post', '/api/getSnipStatus', { user: { uid: 'alice' }, body: { job_uid } });
assert.strictEqual(status.res.body.status, 'failed');
assert.strictEqual(status.res.body.error, 'Snip failed unexpectedly');
// Completed jobs age out while running ones remain visible.
const stored = harness.read('snipJobs');
stored[job_uid].finished = Date.now() - 31 * 60 * 1000;
harness.context.pruneSnipJobs();
assert.strictEqual(stored[job_uid], undefined);
assert.strictEqual(harness.context.countActiveSnipJobs(), 2);
jobs[1].resolve({ success: false, error: 'invalid range' });
jobs[2].resolve({ success: true, file: { uid: 'snip' } });
await new Promise(resolve => setImmediate(resolve));
});

it('reports failed playlist deletion while preserving its partial counts', async function() {
const db = { initialize() {}, getRecord: async () => ({ id: 'mix', uids: ['clip'] }), removeRecord: async () => true };
harness = createRouteHarness({ dependencies: {
'./db': db, './files': { deleteFilesInBatches: async () => ({ deleted_count: 2, failed_count: 1 }) }
}});
const request = { body: { playlist_id: 'mix', delete_files: true } };
const result = await harness.request('post', '/api/deletePlaylist', request);
assert.deepStrictEqual(plain(result.res.body), { success: false, playlist_removed: true, deleted_file_count: 2, failed_file_count: 1 });
db.removeRecord = async () => { throw new Error('database unavailable'); };
assert.strictEqual((await harness.request('post', '/api/deletePlaylist', request)).res.body.success, false);
});

it('reports sharing write failures and refuses malformed file updates', async function() {
harness = createRouteHarness({ dependencies: { './db': {
initialize() {}, updateRecord: async () => { throw new Error('database unavailable'); }
} }});
for (const route of ['/api/enableSharing', '/api/disableSharing']) {
assert.strictEqual((await harness.request('post', route, { body: { uid: 'clip' } })).res.body.success, false);
}
for (const change_obj of [null, 'title', []]) {
assert.strictEqual((await harness.request('post', '/api/updateFile', { body: { uid: 'clip', change_obj } })).res.body.error, 'No changes provided');
}
});

it('reports account update and deletion exceptions instead of successful mutations', async function() {
const auth = require('../authentication/auth');
harness = createRouteHarness({ dependencies: {
'./authentication/auth': { ...auth, initialize() {}, deleteUser: async () => { throw new Error('database unavailable'); } },
'./authentication/shadow': { updateUser: async () => { throw new Error('database unavailable'); } }
}});
for (const route of ['/api/updateUser', '/api/deleteUser']) {
assert.strictEqual((await harness.request('post', route, { body: { uid: 'alice' } })).res.body.success, false);
}
});
});
Loading
Loading