From a97baf592115973a74aeb5f7c20c2293d6b93480 Mon Sep 17 00:00:00 2001 From: Mathias Palmqvist Date: Thu, 12 Nov 2026 09:18:50 +0100 Subject: [PATCH] Accept text/plain events from the Plausible tracker The Plausible tracker posts its JSON payload with Content-Type text/plain, which Laravel does not parse, so every real event failed validation and was redirected instead of being forwarded. - Decode the raw body when the request is not JSON - Respond with 400 instead of a redirect on invalid non-JSON requests - Trim trailing slash from the configured domain when forwarding events - Add a 5s timeout and respond with 502 when Plausible is unreachable --- .../PlausibleEventApiController.php | 20 +++++-- src/Http/Requests/PlausibleEventRequest.php | 28 ++++++++++ tests/PlausibleProxyTest.php | 54 +++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/Http/Controllers/PlausibleEventApiController.php b/src/Http/Controllers/PlausibleEventApiController.php index 7a17ff2..88944ce 100644 --- a/src/Http/Controllers/PlausibleEventApiController.php +++ b/src/Http/Controllers/PlausibleEventApiController.php @@ -2,18 +2,30 @@ namespace Mattitja\LaravelPlausibleProxy\Http\Controllers; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Response; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Mattitja\LaravelPlausibleProxy\Http\Requests\PlausibleEventRequest; class PlausibleEventApiController { public function __invoke(PlausibleEventRequest $request): Response { - $upstream = Http::withHeaders([ - 'User-Agent' => (string) $request->userAgent(), - 'X-Forwarded-For' => (string) $request->ip(), - ])->post(config('plausible-proxy.domain').'/api/event', $request->all()); + $url = rtrim((string) config('plausible-proxy.domain'), '/').'/api/event'; + + try { + $upstream = Http::timeout(5) + ->withHeaders([ + 'User-Agent' => (string) $request->userAgent(), + 'X-Forwarded-For' => (string) $request->ip(), + ]) + ->post($url, $request->all()); + } catch (ConnectionException $e) { + Log::error('Failed to forward Plausible event: '.$e->getMessage()); + + return response('Bad Gateway', 502); + } return response($upstream->body(), $upstream->status()) ->header('Content-Type', $upstream->header('Content-Type') ?: 'text/plain'); diff --git a/src/Http/Requests/PlausibleEventRequest.php b/src/Http/Requests/PlausibleEventRequest.php index a9b8015..089fef8 100644 --- a/src/Http/Requests/PlausibleEventRequest.php +++ b/src/Http/Requests/PlausibleEventRequest.php @@ -2,7 +2,9 @@ namespace Mattitja\LaravelPlausibleProxy\Http\Requests; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Http\Exceptions\HttpResponseException; class PlausibleEventRequest extends FormRequest { @@ -11,6 +13,32 @@ class PlausibleEventRequest extends FormRequest return true; } + /** + * The Plausible tracker sends its JSON payload with a text/plain content + * type, which Laravel does not parse automatically. + */ + protected function prepareForValidation(): void + { + if ($this->isJson()) { + return; + } + + $payload = json_decode($this->getContent(), true); + + if (is_array($payload)) { + $this->merge($payload); + } + } + + protected function failedValidation(Validator $validator): void + { + if (! $this->expectsJson()) { + throw new HttpResponseException(response('Bad Request', 400)); + } + + parent::failedValidation($validator); + } + public function rules(): array { return [ diff --git a/tests/PlausibleProxyTest.php b/tests/PlausibleProxyTest.php index 7aa31fd..c7a11e0 100644 --- a/tests/PlausibleProxyTest.php +++ b/tests/PlausibleProxyTest.php @@ -1,5 +1,6 @@ 'not-json', ])->assertStatus(422)->assertJsonValidationErrors(['p']); }); + +it('accepts events sent as text/plain by the plausible tracker', function () { + Http::fake([ + 'https://plausible.io/api/event' => Http::response('ok', 202), + ]); + + $this->call('POST', '/api/event', [], [], [], ['CONTENT_TYPE' => 'text/plain'], json_encode([ + 'n' => 'pageview', + 'u' => 'https://example.com/page', + 'd' => 'example.com', + 'r' => null, + ]))->assertStatus(202); + + Http::assertSent(fn ($request) => $request->url() === 'https://plausible.io/api/event' + && $request['n'] === 'pageview' + && $request['u'] === 'https://example.com/page' + && $request['d'] === 'example.com'); +}); + +it('responds with 400 for invalid text/plain events', function () { + Http::fake(); + + $this->call('POST', '/api/event', [], [], [], ['CONTENT_TYPE' => 'text/plain'], 'not-json') + ->assertStatus(400); + + Http::assertNothingSent(); +}); + +it('handles a trailing slash in the domain when forwarding events', function () { + config()->set('plausible-proxy.domain', 'https://plausible.example.com/'); + + Http::fake([ + 'https://plausible.example.com/api/event' => Http::response('ok', 202), + ]); + + $this->postJson('/api/event', [ + 'n' => 'pageview', + 'u' => 'https://example.com/page', + 'd' => 'example.com', + ])->assertStatus(202); + + Http::assertSent(fn ($request) => $request->url() === 'https://plausible.example.com/api/event'); +}); + +it('responds with 502 when plausible is unreachable', function () { + Http::fake(fn () => throw new ConnectionException('Connection refused')); + + $this->postJson('/api/event', [ + 'n' => 'pageview', + 'u' => 'https://example.com/page', + 'd' => 'example.com', + ])->assertStatus(502); +});