Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions symfony/src/Controller/ProwlarrController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])]
Expand Down
14 changes: 13 additions & 1 deletion symfony/src/Service/Media/ProwlarrClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -220,14 +220,26 @@ 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'] ?? [],
'fileName' => $r['fileName'] ?? null,
], $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
Expand Down
53 changes: 49 additions & 4 deletions symfony/templates/prowlarr/index.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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, '&quot;'); }
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) {
Expand Down Expand Up @@ -831,11 +839,17 @@
countLabel = countLabel.replace('#', items.length);
var h = '<div class="small text-secondary mb-2">' + esc(countLabel) + '</div>';
h += '<div class="table-responsive pw-search-results"><table class="table table-vcenter table-sm">';
h += '<thead><tr><th>' + esc(_I18N.searchColTitle) + '</th><th>' + esc(_I18N.searchColIndexer) + '</th><th>' + esc(_I18N.searchColSize) + '</th><th>' + esc(_I18N.searchColSeeds) + '</th><th>' + esc(_I18N.searchColAge) + '</th></tr></thead><tbody>';
h += '<thead><tr><th>' + esc(_I18N.searchColTitle) + '</th><th>' + esc(_I18N.searchColIndexer) + '</th><th>' + esc(_I18N.searchColSize) + '</th><th>' + esc(_I18N.searchColSeeds) + '</th><th>' + esc(_I18N.searchColAge) + '</th><th>' + esc(_I18N.searchColActions) + '</th></tr></thead><tbody>';
items.forEach(function (r) {
var size = r.size > 0 ? (r.size / 1073741824).toFixed(2) + ' ' + _I18N.sizeUnits : '—';
var seeds = r.seeders !== null ? '<span class="text-success">' + r.seeders + '</span>' : '—';
h += '<tr class="pw-result-row"><td class="text-truncate" style="max-width:300px;" title="' + esc(r.title||'') + '">' + esc(r.title||'—') + '</td><td><span class="badge bg-secondary-lt" style="font-size:.55rem;">' + esc(r.indexer||'—') + '</span></td><td class="text-secondary">' + size + '</td><td>' + seeds + '</td><td class="text-secondary">' + _I18N.ageDaysTpl.replace('__D__', r.age||0) + '</td></tr>';
// Issue #71 — tracker title links to Prowlarr's manual-search
// detail page when the indexer provided one.
var titleHtml = r.infoUrl
? '<a href="' + esc(r.infoUrl) + '" target="_blank" rel="noopener">' + esc(r.title||'—') + '</a>'
: esc(r.title||'—');
var grabBtn = '<button type="button" class="btn btn-sm btn-outline-primary pw-grab-btn" data-guid="' + esc(r.guid||'') + '" data-indexer-id="' + esc(String(r.indexerId||0)) + '">' + esc(_I18N.searchGrabBtn) + '</button>';
h += '<tr class="pw-result-row"><td class="text-truncate" style="max-width:300px;" title="' + esc(r.title||'') + '">' + titleHtml + '</td><td><span class="badge bg-secondary-lt" style="font-size:.55rem;">' + esc(r.indexer||'—') + '</span></td><td class="text-secondary">' + size + '</td><td>' + seeds + '</td><td class="text-secondary">' + _I18N.ageDaysTpl.replace('__D__', r.age||0) + '</td><td>' + grabBtn + '</td></tr>';
});
h += '</tbody></table></div>';
searchResults.innerHTML = h;
Expand All @@ -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;
Expand Down
83 changes: 83 additions & 0 deletions symfony/tests/Controller/ProwlarrGrabTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

namespace App\Tests\Controller;

use App\Controller\ProwlarrController;
use App\Service\ConfigService;
use App\Service\Media\ProwlarrClient;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
* Prowlarr search results — Grab action (upstream #71): sends a release's
* `guid` + `indexerId` to Prowlarr, which routes the grab to the indexer's
* configured download client (no client picker on our side). Missing or
* invalid input is rejected with 400 before the upstream client is ever
* touched.
*/
#[AllowMockObjectsWithoutExpectations]
class ProwlarrGrabTest extends TestCase
{
private function controller(?ProwlarrClient $prowlarr = null): ProwlarrController
{
$controller = new ProwlarrController(
$prowlarr ?? $this->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));
}
}
70 changes: 70 additions & 0 deletions symfony/tests/Service/Media/ProwlarrSafeInfoUrlTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace App\Tests\Service\Media;

use App\Service\Media\ProwlarrClient;
use PHPUnit\Framework\TestCase;

/**
* Covers ProwlarrClient::safeInfoUrl() — the guard search() applies to the
* indexer-supplied `infoUrl` before it's ever handed to the frontend as an
* <a href>. 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,<script>alert(1)</script>'));
}

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'));
}
}
4 changes: 4 additions & 0 deletions symfony/translations/messages+intl-icu.en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 4 additions & 0 deletions symfony/translations/messages+intl-icu.fr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down