From f3fb7de16330c4fb422142ff2073ccde6d36e52f Mon Sep 17 00:00:00 2001 From: ndandan Date: Sat, 22 Aug 2026 18:21:55 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(prowlarr):=20grab=20search=20results?= =?UTF-8?q?=20to=20the=20download=20client=20+=20tracker=20links=20?= =?UTF-8?q?=E2=80=94=20upstream=20#71=20#35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Grab action to Prowlarr manual-search results and makes the result title a link to the tracker's detail page when Prowlarr provides one. - ProwlarrClient::grab(guid, indexerId) POSTs to /api/v1/search, the same endpoint Prowlarr's own UI uses to route a grab to the indexer's configured download client (no client picker on our side). - New POST /prowlarr/grab route (prowlarr_grab) validates guid/indexerId and 400s with {ok:false,error:'invalid_request'} before touching the upstream client; otherwise returns ProwlarrClient::grab()'s result verbatim. - doSearch() rows gain an Actions column (grab button) and wrap the title in an when the indexer supplied one (folding in #35's spirit for this page). One delegated click listener on the results container handles the grab request, toasting success/failure and re-enabling the button on failure. - New prowlarr.search.* keys in both locale files (grab, grab_sent, grab_failed_tpl); reuses the existing prowlarr.common.actions key for the new column header. Co-Authored-By: Claude Fable 5 --- symfony/src/Controller/ProwlarrController.php | 12 +++ symfony/src/Service/Media/ProwlarrClient.php | 6 ++ symfony/templates/prowlarr/index.html.twig | 47 ++++++++++- symfony/tests/Controller/ProwlarrGrabTest.php | 83 +++++++++++++++++++ .../translations/messages+intl-icu.en.yaml | 4 + .../translations/messages+intl-icu.fr.yaml | 4 + 6 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 symfony/tests/Controller/ProwlarrGrabTest.php diff --git a/symfony/src/Controller/ProwlarrController.php b/symfony/src/Controller/ProwlarrController.php index 880f3cea..cf7cf0c6 100644 --- a/symfony/src/Controller/ProwlarrController.php +++ b/symfony/src/Controller/ProwlarrController.php @@ -139,6 +139,18 @@ public function search(Request $request): JsonResponse return $this->json($results); } + #[Route('/grab', name: 'grab', methods: ['POST'])] + public function grab(Request $request): JsonResponse + { + $data = $request->toArray(); + $guid = (string) ($data['guid'] ?? ''); + $indexerId = (int) ($data['indexerId'] ?? 0); + if ($guid === '' || $indexerId <= 0) { + return $this->json(['ok' => false, 'error' => 'invalid_request'], 400); + } + return $this->json($this->prowlarr->grab($guid, $indexerId)); + } + // ── History ──────────────────────────────────────────────────────────── #[Route('/history', name: 'history', methods: ['GET'])] diff --git a/symfony/src/Service/Media/ProwlarrClient.php b/symfony/src/Service/Media/ProwlarrClient.php index 2c17c569..78d22adb 100644 --- a/symfony/src/Service/Media/ProwlarrClient.php +++ b/symfony/src/Service/Media/ProwlarrClient.php @@ -228,6 +228,12 @@ public function search(string $query, ?int $indexerId = null, string $type = 'se ], $data); } + /** Grab a search result via the indexer's configured download client. */ + public function grab(string $guid, int $indexerId): array + { + return $this->requestWithError('POST', '/api/v1/search', ['guid' => $guid, 'indexerId' => $indexerId]); + } + // ── History ─────────────────────────────────────────────────────────────── public function getRecentSearches(int $limit = 20): array diff --git a/symfony/templates/prowlarr/index.html.twig b/symfony/templates/prowlarr/index.html.twig index 352f7ea7..4bb73927 100644 --- a/symfony/templates/prowlarr/index.html.twig +++ b/symfony/templates/prowlarr/index.html.twig @@ -501,7 +501,11 @@ infoPrivacyPrivate: {{ 'prowlarr.index.privacy_private'|trans|json_encode|raw }}, infoPrivacySemi: {{ 'prowlarr.index.privacy_semiprivate'|trans|json_encode|raw }}, infoPrivacyPublic: {{ 'prowlarr.index.privacy_public'|trans|json_encode|raw }}, - listPrivacySemi: {{ 'prowlarr.index.privacy_semi'|trans|json_encode|raw }} + listPrivacySemi: {{ 'prowlarr.index.privacy_semi'|trans|json_encode|raw }}, + searchColActions: {{ 'prowlarr.common.actions'|trans|json_encode|raw }}, + searchGrabBtn: {{ 'prowlarr.search.grab'|trans|json_encode|raw }}, + searchGrabSent: {{ 'prowlarr.search.grab_sent'|trans|json_encode|raw }}, + searchGrabFailedTpl: {{ 'prowlarr.search.grab_failed_tpl'|trans|json_encode|raw }} }; // ── Helpers ── @@ -831,11 +835,17 @@ countLabel = countLabel.replace('#', items.length); var h = '
' + esc(countLabel) + '
'; h += '
'; - h += ''; + h += ''; items.forEach(function (r) { var size = r.size > 0 ? (r.size / 1073741824).toFixed(2) + ' ' + _I18N.sizeUnits : '—'; var seeds = r.seeders !== null ? '' + r.seeders + '' : '—'; - h += ''; + // Issue #71 — tracker title links to Prowlarr's manual-search + // detail page when the indexer provided one. + var titleHtml = r.infoUrl + ? '' + esc(r.title||'—') + '' + : esc(r.title||'—'); + var grabBtn = ''; + h += ''; }); h += '
' + esc(_I18N.searchColTitle) + '' + esc(_I18N.searchColIndexer) + '' + esc(_I18N.searchColSize) + '' + esc(_I18N.searchColSeeds) + '' + esc(_I18N.searchColAge) + '
' + esc(_I18N.searchColTitle) + '' + esc(_I18N.searchColIndexer) + '' + esc(_I18N.searchColSize) + '' + esc(_I18N.searchColSeeds) + '' + esc(_I18N.searchColAge) + '' + esc(_I18N.searchColActions) + '
' + esc(r.title||'—') + '' + esc(r.indexer||'—') + '' + size + '' + seeds + '' + _I18N.ageDaysTpl.replace('__D__', r.age||0) + '
' + titleHtml + '' + esc(r.indexer||'—') + '' + size + '' + seeds + '' + _I18N.ageDaysTpl.replace('__D__', r.age||0) + '' + grabBtn + '
'; searchResults.innerHTML = h; @@ -845,6 +855,37 @@ searchBtn.addEventListener('click', doSearch); searchInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') doSearch(); }); + // Delegated on the results container — rows are rebuilt on every search, + // so a per-row listener would be silently dropped each time. + searchResults.addEventListener('click', function (e) { + var btn = e.target.closest ? e.target.closest('.pw-grab-btn') : null; + if (!btn) return; + var guid = btn.dataset.guid || ''; + var indexerId = parseInt(btn.dataset.indexerId, 10) || 0; + if (!guid || !indexerId) return; + btn.disabled = true; + fetch('/prowlarr/grab', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ guid: guid, indexerId: indexerId }), + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + var ok = d && d.ok !== false; + if (ok) { + btn.textContent = '✓'; + if (window._prismarrToast) window._prismarrToast(_I18N.searchGrabSent, 'success'); + } else { + btn.disabled = false; + if (window._prismarrToast) window._prismarrToast(_I18N.searchGrabFailedTpl.replace('__ERROR__', (d && d.error) || _I18N.commonError), 'danger'); + } + }) + .catch(function () { + btn.disabled = false; + if (window._prismarrToast) window._prismarrToast(_I18N.searchGrabFailedTpl.replace('__ERROR__', _I18N.commonError), 'danger'); + }); + }); + // ══ Modal Ajouter ══ var schemaCache = null; var selectedSchema = null; diff --git a/symfony/tests/Controller/ProwlarrGrabTest.php b/symfony/tests/Controller/ProwlarrGrabTest.php new file mode 100644 index 00000000..4b48369a --- /dev/null +++ b/symfony/tests/Controller/ProwlarrGrabTest.php @@ -0,0 +1,83 @@ +createMock(ProwlarrClient::class), + $this->createMock(ConfigService::class), + new NullLogger(), + $this->createMock(TranslatorInterface::class), + ); + // Empty container so AbstractController::json() falls back to a plain + // JsonResponse instead of looking up the serializer service. + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->willReturn(false); + $controller->setContainer($container); + return $controller; + } + + private function grabRequest(array $body): Request + { + return Request::create('/prowlarr/grab', 'POST', [], [], [], [], json_encode($body)); + } + + public function testValidGrabCallsClientAndReturnsItsResultVerbatim(): void + { + $prowlarr = $this->createMock(ProwlarrClient::class); + $prowlarr->expects($this->once()) + ->method('grab') + ->with('g', 3) + ->willReturn(['ok' => true, 'data' => ['id' => 42]]); + + $res = $this->controller($prowlarr)->grab($this->grabRequest(['guid' => 'g', 'indexerId' => 3])); + + $this->assertInstanceOf(JsonResponse::class, $res); + $this->assertSame(200, $res->getStatusCode()); + $this->assertSame(['ok' => true, 'data' => ['id' => 42]], json_decode($res->getContent(), true)); + } + + public function testMissingGuidReturns400WithoutCallingClient(): void + { + $prowlarr = $this->createMock(ProwlarrClient::class); + $prowlarr->expects($this->never())->method('grab'); + + $res = $this->controller($prowlarr)->grab($this->grabRequest(['indexerId' => 3])); + + $this->assertSame(400, $res->getStatusCode()); + $this->assertSame(['ok' => false, 'error' => 'invalid_request'], json_decode($res->getContent(), true)); + } + + public function testZeroIndexerIdReturns400WithoutCallingClient(): void + { + $prowlarr = $this->createMock(ProwlarrClient::class); + $prowlarr->expects($this->never())->method('grab'); + + $res = $this->controller($prowlarr)->grab($this->grabRequest(['guid' => 'g', 'indexerId' => 0])); + + $this->assertSame(400, $res->getStatusCode()); + $this->assertSame(['ok' => false, 'error' => 'invalid_request'], json_decode($res->getContent(), true)); + } +} diff --git a/symfony/translations/messages+intl-icu.en.yaml b/symfony/translations/messages+intl-icu.en.yaml index 992510a6..5f2b3b4d 100644 --- a/symfony/translations/messages+intl-icu.en.yaml +++ b/symfony/translations/messages+intl-icu.en.yaml @@ -4550,6 +4550,10 @@ prowlarr: indexer_added: Indexer added! error_add_failed: Unable to add error_save_failed: Unable to save + search: + grab: Grab + grab_sent: Sent to download client + grab_failed_tpl: 'Grab failed: __ERROR__' apps: title: Applications page_title_full: 'Applications — Prowlarr' diff --git a/symfony/translations/messages+intl-icu.fr.yaml b/symfony/translations/messages+intl-icu.fr.yaml index c4414b27..6348fe62 100644 --- a/symfony/translations/messages+intl-icu.fr.yaml +++ b/symfony/translations/messages+intl-icu.fr.yaml @@ -4548,6 +4548,10 @@ prowlarr: indexer_added: Indexeur ajouté ! error_add_failed: Ajout impossible error_save_failed: Sauvegarde impossible + search: + grab: Récupérer + grab_sent: Envoyé au client de téléchargement + grab_failed_tpl: 'Échec de la récupération : __ERROR__' apps: title: Applications page_title_full: 'Applications — Prowlarr' From 8fd29606739239dcc6bb874b9ec5f6b9a0f0de0c Mon Sep 17 00:00:00 2001 From: ndandan Date: Sat, 22 Aug 2026 18:31:08 -0500 Subject: [PATCH 2/2] fix(prowlarr): escape quotes in attribute contexts + http(s)-only tracker links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix-round-1 for the #71 grab-action commit: - esc() (prowlarr/index.html.twig) escaped only & < > via the textContent->innerHTML round-trip. That's fine for text nodes but this page also interpolates esc() output into attributes (href, data-guid, data-indexer-id) — a `"` in indexer-supplied guid/infoUrl broke out of the attribute. Appends .replace(/"/g, '"') to the existing helper: behavior-preserving for text contexts, closes the attribute-injection gap for the new grab button + tracker link. - ProwlarrClient::search() now maps infoUrl through a new private static safeInfoUrl(), only passing through http(s) URLs (case-insensitive scheme match) — this fork's CSP blocks javascript: navigation, but this commit becomes a standalone upstream PR and upstream has no such CSP. Mirrors the scheme-allowlist pattern used by the parallel Radarr/Sonarr infoUrl task. Adds ProwlarrSafeInfoUrlTest (reflection-invoked, same convention as ClientErrorExtractionTest since the guard is private static): rejects javascript:/data:/scheme-relative/non-string/empty, keeps http(s) including an uppercase-scheme URL. Co-Authored-By: Claude Fable 5 --- symfony/src/Service/Media/ProwlarrClient.php | 8 ++- symfony/templates/prowlarr/index.html.twig | 6 +- .../Service/Media/ProwlarrSafeInfoUrlTest.php | 70 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 symfony/tests/Service/Media/ProwlarrSafeInfoUrlTest.php diff --git a/symfony/src/Service/Media/ProwlarrClient.php b/symfony/src/Service/Media/ProwlarrClient.php index 78d22adb..23eeb785 100644 --- a/symfony/src/Service/Media/ProwlarrClient.php +++ b/symfony/src/Service/Media/ProwlarrClient.php @@ -220,7 +220,7 @@ public function search(string $query, ?int $indexerId = null, string $type = 'se 'tvdbId' => $r['tvdbId'] ?? null, 'categories' => $r['categories'] ?? [], 'downloadUrl' => $r['downloadUrl'] ?? null, - 'infoUrl' => $r['infoUrl'] ?? null, + 'infoUrl' => self::safeInfoUrl($r['infoUrl'] ?? null), 'infoHash' => $r['infoHash'] ?? null, 'publishDate' => $r['publishDate'] ?? null, 'indexerFlags' => $r['indexerFlags'] ?? [], @@ -228,6 +228,12 @@ public function search(string $query, ?int $indexerId = null, string $type = 'se ], $data); } + /** Only http(s) URLs are safe to emit as release tracker links. */ + private static function safeInfoUrl(mixed $url): ?string + { + return (is_string($url) && preg_match('~^https?://~i', $url) === 1) ? $url : null; + } + /** Grab a search result via the indexer's configured download client. */ public function grab(string $guid, int $indexerId): array { diff --git a/symfony/templates/prowlarr/index.html.twig b/symfony/templates/prowlarr/index.html.twig index 4bb73927..e680885c 100644 --- a/symfony/templates/prowlarr/index.html.twig +++ b/symfony/templates/prowlarr/index.html.twig @@ -509,7 +509,11 @@ }; // ── Helpers ── - function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; } + // Issue #71 fix-round-1 — textContent→innerHTML only escapes & < > ; + // this page also interpolates esc() output into attribute contexts + // (href, data-guid, data-indexer-id), so " must be escaped too or a + // quote in indexer-supplied data breaks out of the attribute. + function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML.replace(/"/g, '"'); } function showAlert(el, cls, msg) { el.className = 'mt-2 alert ' + cls; el.textContent = msg; el.style.display = ''; } // Clean the payload before sending to the Prowlarr API (remove empty selectOptions that cause type errors) function cleanPayload(data) { diff --git a/symfony/tests/Service/Media/ProwlarrSafeInfoUrlTest.php b/symfony/tests/Service/Media/ProwlarrSafeInfoUrlTest.php new file mode 100644 index 00000000..794d3872 --- /dev/null +++ b/symfony/tests/Service/Media/ProwlarrSafeInfoUrlTest.php @@ -0,0 +1,70 @@ +. Upstream (this becomes a standalone PR — #71) has no CSP to + * fall back on, so a `javascript:`/`data:` scheme in a hostile indexer's + * response must be neutralized here, not just relied on client-side. + * + * The method is private static; invoked via reflection rather than + * widening the production API (same convention as ClientErrorExtractionTest). + */ +class ProwlarrSafeInfoUrlTest extends TestCase +{ + private function call(mixed $url): ?string + { + $ref = new \ReflectionClass(ProwlarrClient::class); + $method = $ref->getMethod('safeInfoUrl'); + $method->setAccessible(true); + + return $method->invoke(null, $url); + } + + public function testJavascriptSchemeIsRejected(): void + { + $this->assertNull($this->call('javascript:alert(1)')); + } + + public function testDataSchemeIsRejected(): void + { + $this->assertNull($this->call('data:text/html,')); + } + + public function testHttpUrlIsKept(): void + { + $this->assertSame('http://tracker.example.com/details?id=1', $this->call('http://tracker.example.com/details?id=1')); + } + + public function testHttpsUrlIsKept(): void + { + $this->assertSame('https://tracker.example.com/details?id=1', $this->call('https://tracker.example.com/details?id=1')); + } + + public function testUppercaseSchemeIsKept(): void + { + $this->assertSame('HTTPS://tracker.example.com/details?id=1', $this->call('HTTPS://tracker.example.com/details?id=1')); + } + + public function testNonStringInputIsRejected(): void + { + $this->assertNull($this->call(null)); + $this->assertNull($this->call(123)); + $this->assertNull($this->call(['not' => 'a string'])); + } + + public function testEmptyStringIsRejected(): void + { + $this->assertNull($this->call('')); + } + + public function testSchemeRelativeUrlIsRejected(): void + { + $this->assertNull($this->call('//tracker.example.com/details?id=1')); + } +}