diff --git a/src/php/src/Api/Controllers/Api/UnifiedPixelController.php b/src/php/src/Api/Controllers/Api/UnifiedPixelController.php new file mode 100644 index 0000000..d7e51c2 --- /dev/null +++ b/src/php/src/Api/Controllers/Api/UnifiedPixelController.php @@ -0,0 +1,50 @@ + transparent GIF + * POST /api/pixel/abc12345 -> 204 No Content + */ + #[RateLimit(limit: 10000, window: 60)] + public function track(Request $request, string $pixelKey): Response + { + if ($request->isMethod('post')) { + return response()->noContent() + ->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->header('Pragma', 'no-cache') + ->header('Expires', '0'); + } + + $pixel = base64_decode(self::TRANSPARENT_GIF); + + return response($pixel, 200) + ->header('Content-Type', 'image/gif') + ->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->header('Pragma', 'no-cache') + ->header('Expires', '0') + ->header('Content-Length', (string) strlen($pixel)); + } +} diff --git a/src/php/src/Api/Exceptions/RateLimitExceededException.php b/src/php/src/Api/Exceptions/RateLimitExceededException.php index d96702a..ac764cd 100644 --- a/src/php/src/Api/Exceptions/RateLimitExceededException.php +++ b/src/php/src/Api/Exceptions/RateLimitExceededException.php @@ -7,6 +7,7 @@ namespace Core\Api\Exceptions; use Core\Api\RateLimit\RateLimitResult; use Core\Api\Concerns\HasApiResponses; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Symfony\Component\HttpKernel\Exception\HttpException; /** @@ -36,9 +37,9 @@ class RateLimitExceededException extends HttpException /** * Render the exception as a JSON response. */ - public function render(): JsonResponse + public function render(?Request $request = null): JsonResponse { - return $this->errorResponse( + $response = $this->errorResponse( errorCode: 'rate_limit_exceeded', message: $this->getMessage(), meta: [ @@ -48,6 +49,14 @@ class RateLimitExceededException extends HttpException ], status: 429, )->withHeaders($this->rateLimitResult->headers()); + + if ($request !== null) { + $origin = $request->headers->get('Origin', '*'); + $response->headers->set('Access-Control-Allow-Origin', $origin); + $response->headers->set('Vary', 'Origin'); + } + + return $response; } /** diff --git a/src/php/src/Api/Routes/api.php b/src/php/src/Api/Routes/api.php index cec4478..638744e 100644 --- a/src/php/src/Api/Routes/api.php +++ b/src/php/src/Api/Routes/api.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use Core\Api\Controllers\Api\UnifiedPixelController; use Core\Api\Controllers\McpApiController; +use Core\Api\Middleware\PublicApiCors; use Core\Mcp\Middleware\McpApiKeyAuth; use Illuminate\Support\Facades\Route; @@ -13,11 +15,23 @@ use Illuminate\Support\Facades\Route; | | Core API routes for cross-cutting concerns. | -| TODO: SeoReportController, UnifiedPixelController, EntitlementApiController -| are planned but not yet implemented. Re-add routes when controllers exist. +| TODO: SeoReportController and EntitlementApiController are planned but not +| yet implemented. Re-add routes when controllers exist. | */ +// ───────────────────────────────────────────────────────────────────────────── +// Unified Pixel (public tracking) +// ───────────────────────────────────────────────────────────────────────────── + +Route::middleware([PublicApiCors::class, 'api.rate']) + ->prefix('pixel') + ->name('api.pixel.') + ->group(function () { + Route::match(['GET', 'POST', 'OPTIONS'], '/{pixelKey}', [UnifiedPixelController::class, 'track']) + ->name('track'); + }); + // ───────────────────────────────────────────────────────────────────────────── // MCP HTTP Bridge (API key auth) // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/php/src/Api/Tests/Feature/PixelEndpointTest.php b/src/php/src/Api/Tests/Feature/PixelEndpointTest.php new file mode 100644 index 0000000..1e58d75 --- /dev/null +++ b/src/php/src/Api/Tests/Feature/PixelEndpointTest.php @@ -0,0 +1,48 @@ +get('/api/pixel/abc12345', [ + 'Origin' => 'https://example.com', + ]); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'image/gif'); + $response->assertHeader('Access-Control-Allow-Origin', 'https://example.com'); + $response->assertHeader('X-RateLimit-Limit', '10000'); + $response->assertHeader('X-RateLimit-Remaining', '9999'); + + expect($response->getContent())->toBe(base64_decode('R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==')); +}); + +it('accepts post tracking requests without a body', function () { + $response = $this->post('/api/pixel/abc12345', [], [ + 'Origin' => 'https://example.com', + ]); + + $response->assertNoContent(); + $response->assertHeader('Access-Control-Allow-Origin', 'https://example.com'); + $response->assertHeader('X-RateLimit-Limit', '10000'); + $response->assertHeader('X-RateLimit-Remaining', '9999'); +}); + +it('handles preflight requests for public pixel tracking', function () { + $response = $this->call('OPTIONS', '/api/pixel/abc12345', [], [], [], [ + 'HTTP_ORIGIN' => 'https://example.com', + ]); + + $response->assertNoContent(); + $response->assertHeader('Access-Control-Allow-Origin', 'https://example.com'); + $response->assertHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); +});