Serve uploaded SVGs under their CSP, and identify the image fetcher

The Content-Security-Policy meant for stored SVGs never applied: in
Caddy a `*` in the middle of a path does not cross a `/`, so
`path /storage/*.svg` only matched files directly under /storage, and
every upload lives in a directory below it. Two matchers, which Caddy
ANDs, cover any depth.

RemoteImage fetched with Guzzle's anonymous default User-Agent, which
Wikimedia answers with 403. It now sends `<app>/1.0 (+<app url>; media
fetcher)`, the identification such hosts ask of automated clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 20:43:59 +02:00
parent ac546eba2b
commit 7e672a5e62
3 changed files with 48 additions and 1 deletions

View File

@@ -133,6 +133,26 @@ class MediaUploadTest extends TestCase
->assertJsonPath('data.width', 30);
}
#[Test]
public function it_says_who_is_fetching(): void
{
config()->set('app.name', 'Pireon Admin');
config()->set('app.url', 'https://admin.example.org');
$this->actingWithRole('editor');
$this->resolving(['images.example.test' => ['93.184.216.34']]);
Http::fake([
'https://images.example.test/*' => Http::response($this->png(), 200),
]);
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])->assertCreated();
// Hosts such as Wikimedia answer Guzzle's anonymous default with 403.
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://images.example.test/')
&& $request->header('User-Agent') === ['pireon-admin/1.0 (+https://admin.example.org; media fetcher)']);
}
/**
* @return array<string, array{0: array<string, array<int, string>>, 1: string}>
*/