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..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,18 @@ 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
+ {
+ 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..e680885c 100644
--- a/symfony/templates/prowlarr/index.html.twig
+++ b/symfony/templates/prowlarr/index.html.twig
@@ -501,11 +501,19 @@
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 ──
- 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) {
@@ -831,11 +839,17 @@
countLabel = countLabel.replace('#', items.length);
var h = '
' + esc(countLabel) + '
';
h += '';
- h += '| ' + esc(_I18N.searchColTitle) + ' | ' + esc(_I18N.searchColIndexer) + ' | ' + esc(_I18N.searchColSize) + ' | ' + esc(_I18N.searchColSeeds) + ' | ' + esc(_I18N.searchColAge) + ' |
';
+ h += '| ' + esc(_I18N.searchColTitle) + ' | ' + esc(_I18N.searchColIndexer) + ' | ' + esc(_I18N.searchColSize) + ' | ' + esc(_I18N.searchColSeeds) + ' | ' + esc(_I18N.searchColAge) + ' | ' + esc(_I18N.searchColActions) + ' |
';
items.forEach(function (r) {
var size = r.size > 0 ? (r.size / 1073741824).toFixed(2) + ' ' + _I18N.sizeUnits : '—';
var seeds = r.seeders !== null ? '' + r.seeders + '' : '—';
- h += '| ' + esc(r.title||'—') + ' | ' + esc(r.indexer||'—') + ' | ' + size + ' | ' + seeds + ' | ' + _I18N.ageDaysTpl.replace('__D__', r.age||0) + ' |
';
+ // 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 += '| ' + titleHtml + ' | ' + esc(r.indexer||'—') + ' | ' + size + ' | ' + seeds + ' | ' + _I18N.ageDaysTpl.replace('__D__', r.age||0) + ' | ' + grabBtn + ' |
';
});
h += '
';
searchResults.innerHTML = h;
@@ -845,6 +859,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/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'));
+ }
+}
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'