Fix #34: Allow non-admins to use blocklist-search under qualifying conditions
Build and Push Docker Image / build (push) Successful in 46s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 1m40s
CI / Security audit (push) Successful in 2m4s
CI / Swagger Validation & Coverage (push) Successful in 2m10s
CI / Tests & coverage (push) Successful in 2m23s
Build and Push Docker Image / build (push) Successful in 46s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 1m40s
CI / Security audit (push) Successful in 2m4s
CI / Swagger Validation & Coverage (push) Successful in 2m10s
CI / Tests & coverage (push) Successful in 2m23s
- Expose ARR ID fields (arrQueueId, arrType, arrInstanceUrl, arrContentId, arrContentType) to non-admins in DownloadMatcher.js for blocklist functionality - Replace blanket admin check in dashboard.js with canBlocklist() validation and server-side API key lookup - Update integration tests to reflect new permission model - Non-admins can now blocklist downloads with import issues or stale low-availability torrents
This commit is contained in:
@@ -12,8 +12,9 @@ const TagMatcher = require('../services/TagMatcher');
|
||||
const { buildUserDownloads } = require('../services/DownloadBuilder');
|
||||
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||
const { getOmbiInstances } = require('../utils/config');
|
||||
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
||||
const { canBlocklist } = require('../services/DownloadAssembler');
|
||||
|
||||
|
||||
// Track active SSE clients for disconnect cleanup
|
||||
@@ -673,13 +674,9 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
if (!user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body;
|
||||
|
||||
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrInstanceKey || !arrContentId || !arrContentType) {
|
||||
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentId || !arrContentType) {
|
||||
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentId, arrContentType });
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
@@ -687,7 +684,34 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
|
||||
}
|
||||
|
||||
const headers = { 'X-Api-Key': arrInstanceKey };
|
||||
// Look up the download to verify permission
|
||||
const allDownloads = await downloadClientRegistry.getAllDownloads();
|
||||
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType);
|
||||
|
||||
if (!download) {
|
||||
console.error('[Blocklist] Download not found:', { arrQueueId, arrType });
|
||||
return res.status(403).json({ error: 'Download not found or permission denied' });
|
||||
}
|
||||
|
||||
// Check if user can blocklist this download
|
||||
if (!canBlocklist(download, user.isAdmin)) {
|
||||
console.log('[Blocklist] Permission denied:', { user: user.name, isAdmin: user.isAdmin, arrQueueId, arrType });
|
||||
return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' });
|
||||
}
|
||||
|
||||
// Resolve API key: use provided key (admin) or look up from instance config (non-admin)
|
||||
let apiKey = arrInstanceKey;
|
||||
if (!apiKey) {
|
||||
const instances = arrType === 'sonarr' ? getSonarrInstances() : getRadarrInstances();
|
||||
const instance = instances.find(inst => inst.url === arrInstanceUrl);
|
||||
if (!instance || !instance.apiKey) {
|
||||
console.error('[Blocklist] Instance not found or missing API key:', { arrType, arrInstanceUrl });
|
||||
return res.status(400).json({ error: 'Instance not found or missing API key' });
|
||||
}
|
||||
apiKey = instance.apiKey;
|
||||
}
|
||||
|
||||
const headers = { 'X-Api-Key': apiKey };
|
||||
|
||||
// Step 1: Remove from queue with blocklist=true
|
||||
await axios.delete(`${arrInstanceUrl}/api/v3/queue/${arrQueueId}`, {
|
||||
|
||||
@@ -204,16 +204,17 @@ async function matchSabSlots(slots, context) {
|
||||
};
|
||||
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
dlObj.arrQueueId = sonarrMatch.id;
|
||||
dlObj.arrType = 'sonarr';
|
||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||
dlObj.arrContentType = 'episode';
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
dlObj.arrQueueId = sonarrMatch.id;
|
||||
dlObj.arrType = 'sonarr';
|
||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||
dlObj.arrContentType = 'episode';
|
||||
}
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
addOmbiMatching(dlObj, series, context);
|
||||
@@ -257,16 +258,17 @@ async function matchSabSlots(slots, context) {
|
||||
};
|
||||
const issues = DownloadAssembler.getImportIssues(radarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
dlObj.arrQueueId = radarrMatch.id;
|
||||
dlObj.arrType = 'radarr';
|
||||
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
dlObj.arrContentId = radarrMatch.movieId || null;
|
||||
dlObj.arrContentType = 'movie';
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
dlObj.arrQueueId = radarrMatch.id;
|
||||
dlObj.arrType = 'radarr';
|
||||
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
dlObj.arrContentId = radarrMatch.movieId || null;
|
||||
dlObj.arrContentType = 'movie';
|
||||
}
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
addOmbiMatching(dlObj, movie, context);
|
||||
@@ -444,16 +446,17 @@ async function matchTorrents(torrents, context) {
|
||||
});
|
||||
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
|
||||
if (issues) download.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
download.arrQueueId = sonarrMatch.id;
|
||||
download.arrType = 'sonarr';
|
||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
download.arrContentId = sonarrMatch.episodeId || null;
|
||||
download.arrContentType = 'episode';
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
download.arrQueueId = sonarrMatch.id;
|
||||
download.arrType = 'sonarr';
|
||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
download.arrContentId = sonarrMatch.episodeId || null;
|
||||
download.arrContentType = 'episode';
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
addOmbiMatching(download, series, context);
|
||||
@@ -489,16 +492,17 @@ async function matchTorrents(torrents, context) {
|
||||
});
|
||||
const issues = DownloadAssembler.getImportIssues(radarrMatch);
|
||||
if (issues) download.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
download.arrQueueId = radarrMatch.id;
|
||||
download.arrType = 'radarr';
|
||||
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
download.arrContentId = radarrMatch.movieId || null;
|
||||
download.arrContentType = 'movie';
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
download.arrQueueId = radarrMatch.id;
|
||||
download.arrType = 'radarr';
|
||||
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
download.arrContentId = radarrMatch.movieId || null;
|
||||
download.arrContentType = 'movie';
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
addOmbiMatching(download, movie, context);
|
||||
|
||||
@@ -352,7 +352,7 @@ describe('GET /api/dashboard/user-downloads', () => {
|
||||
expect(dl.downloadPath).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not include admin-only fields for non-admin user', async () => {
|
||||
it('includes ARR ID fields for non-admin user (for blocklist functionality)', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
seedSabSonarrCache();
|
||||
@@ -362,8 +362,14 @@ describe('GET /api/dashboard/user-downloads', () => {
|
||||
expect(res.status).toBe(200);
|
||||
const dl = res.body.downloads.find(d => d.type === 'series');
|
||||
expect(dl).toBeDefined();
|
||||
expect(dl.arrQueueId).toBeUndefined();
|
||||
expect(dl.arrType).toBeUndefined();
|
||||
// ARR IDs are now exposed to non-admins for blocklist functionality
|
||||
expect(dl.arrQueueId).toBe(1001);
|
||||
expect(dl.arrType).toBe('sonarr');
|
||||
// But sensitive fields remain admin-only
|
||||
expect(dl.arrInstanceKey).toBeUndefined();
|
||||
expect(dl.arrLink).toBeUndefined();
|
||||
expect(dl.downloadPath).toBeUndefined();
|
||||
expect(dl.targetPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not return downloads tagged for a different user', async () => {
|
||||
@@ -739,17 +745,74 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 403 for non-admin user', async () => {
|
||||
it('returns 403 for non-admin user without qualifying conditions', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf } = await loginAs(app);
|
||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||
|
||||
// Mock getAllDownloads to return a download that doesn't qualify for blocklist
|
||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
||||
{ arrQueueId: 1, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/admin/i);
|
||||
expect(res.body.error).toMatch(/permission denied/i);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
|
||||
it('returns 403 for non-admin when download not found in active downloads', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf } = await loginAs(app);
|
||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||
|
||||
// Mock getAllDownloads to return empty array (download not found)
|
||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/download not found/i);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
|
||||
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf } = await loginAs(app);
|
||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||
|
||||
// Mock getAllDownloads to return a download with import issues (qualifies for blocklist)
|
||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
||||
{ arrQueueId: 1, arrType: 'sonarr', importIssues: ['Import error 1'], qbittorrent: null }
|
||||
]);
|
||||
|
||||
// Mock Sonarr DELETE and command endpoints
|
||||
nock(SONARR_BASE)
|
||||
.delete('/api/v3/queue/1')
|
||||
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||
.reply(200, {});
|
||||
nock(SONARR_BASE)
|
||||
.post('/api/v3/command')
|
||||
.reply(200, {});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
|
||||
it('returns 400 when required fields are missing', async () => {
|
||||
@@ -780,6 +843,12 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
|
||||
// Mock getAllDownloads to return a matching download for admin
|
||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
||||
]);
|
||||
|
||||
nock(SONARR_BASE)
|
||||
.delete('/api/v3/queue/1001')
|
||||
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||
@@ -795,12 +864,19 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
|
||||
it('calls Radarr DELETE+command and returns ok:true', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
|
||||
// Mock getAllDownloads to return a matching download for admin
|
||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
||||
{ arrQueueId: 2001, arrType: 'radarr', importIssues: [], qbittorrent: null }
|
||||
]);
|
||||
|
||||
nock(RADARR_BASE)
|
||||
.delete('/api/v3/queue/2001')
|
||||
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||
@@ -816,12 +892,19 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
||||
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
|
||||
it('returns 502 when Sonarr DELETE request fails', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
|
||||
// Mock getAllDownloads to return a matching download for admin
|
||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
||||
]);
|
||||
|
||||
nock(SONARR_BASE)
|
||||
.delete('/api/v3/queue/1001')
|
||||
.query(true)
|
||||
@@ -833,6 +916,7 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(502);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user