feat(api): add unified pixel tracking endpoint
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
3ead3fed2b
commit
db1efd502c
4 changed files with 125 additions and 4 deletions
50
src/php/src/Api/Controllers/Api/UnifiedPixelController.php
Normal file
50
src/php/src/Api/Controllers/Api/UnifiedPixelController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
48
src/php/src/Api/Tests/Feature/PixelEndpointTest.php
Normal file
48
src/php/src/Api/Tests/Feature/PixelEndpointTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue