diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 10cae14..4891461 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -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}`, { diff --git a/server/services/DownloadMatcher.js b/server/services/DownloadMatcher.js index bc1a1a3..44d2d37 100644 --- a/server/services/DownloadMatcher.js +++ b/server/services/DownloadMatcher.js @@ -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); diff --git a/tests/integration/dashboard.test.js b/tests/integration/dashboard.test.js index ee77ffc..4f4c16f 100644 --- a/tests/integration/dashboard.test.js +++ b/tests/integration/dashboard.test.js @@ -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(); }); });