diff --git a/app/Services/Media/RemoteImage.php b/app/Services/Media/RemoteImage.php
index 6353df5..5cdb999 100644
--- a/app/Services/Media/RemoteImage.php
+++ b/app/Services/Media/RemoteImage.php
@@ -6,6 +6,7 @@ namespace App\Services\Media;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/**
@@ -25,6 +26,10 @@ use Illuminate\Validation\ValidationException;
* - Redirects are not followed: the target of one would need all of the above
* again, and "use the final address" is an easy thing to ask for.
* - The body is read to a ceiling and no further, whatever the headers claim.
+ *
+ * It says who is asking. Hosts such as Wikimedia refuse Guzzle's anonymous
+ * default outright, and a User-Agent with a contact address is what their
+ * policies ask of anything that fetches automatically.
*/
class RemoteImage
{
@@ -63,6 +68,7 @@ class RemoteImage
)]],
])
->withHeaders(['Accept' => 'image/*'])
+ ->withUserAgent($this->userAgent())
->get($url);
} catch (ConnectionException) {
$this->fail('unreachable');
@@ -94,6 +100,19 @@ class RemoteImage
return $contents;
}
+ /**
+ * `pireon-admin/1.0 (+https://admin.example.org; media fetcher)`: a product
+ * token without spaces, and the installation's own address to reach it by.
+ */
+ protected function userAgent(): string
+ {
+ return sprintf(
+ '%s/1.0 (+%s; media fetcher)',
+ Str::slug((string) config('app.name')) ?: 'landing',
+ rtrim((string) config('app.url'), '/'),
+ );
+ }
+
/**
* The address to connect to, once every address the host has is known to
* be public.
diff --git a/docker/Caddyfile b/docker/Caddyfile
index 829e657..30aeea1 100644
--- a/docker/Caddyfile
+++ b/docker/Caddyfile
@@ -25,7 +25,15 @@
# second line: opened directly rather than through an
, an SVG is a
# document, and nothing stored here has any business running script or
# loading anything from elsewhere.
- @svg path /storage/*.svg
+ #
+ # Two matchers, which Caddy ANDs, rather than `path /storage/*.svg`: a `*`
+ # in the middle of a path does not cross a `/`, so that pattern only ever
+ # matched files directly under /storage — and every upload is a directory
+ # below it.
+ @svg {
+ path /storage/*
+ path_regexp (?i)\.svg$
+ }
header @svg Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:"
# The frontend is a separate application on another origin.
diff --git a/tests/Feature/Api/MediaUploadTest.php b/tests/Feature/Api/MediaUploadTest.php
index 0427d4e..48e0bf0 100644
--- a/tests/Feature/Api/MediaUploadTest.php
+++ b/tests/Feature/Api/MediaUploadTest.php
@@ -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>, 1: string}>
*/