feat(api): add unified pixel tracking endpoint

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 06:41:54 +00:00
parent 3ead3fed2b
commit db1efd502c
4 changed files with 125 additions and 4 deletions

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Core\Api\Controllers\Api;
use Core\Api\RateLimit\RateLimit;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
/**
* Unified tracking pixel controller.
*
* GET /api/pixel/{pixelKey} returns a transparent 1x1 GIF for image embeds.
* POST /api/pixel/{pixelKey} returns 204 No Content for fetch-based tracking.
*/
class UnifiedPixelController extends Controller
{
/**
* Transparent 1x1 GIF used by browser pixel embeds.
*/
private const TRANSPARENT_GIF = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
/**
* Track a pixel hit.
*
* GET /api/pixel/abc12345 -> 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));
}
}

View file

@ -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;
}
/**

View file

@ -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)
// ─────────────────────────────────────────────────────────────────────────────

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Cache;
beforeEach(function () {
Cache::flush();
});
afterEach(function () {
Cache::flush();
});
it('returns a transparent gif for get requests', function () {
$response = $this->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');
});