Adds a scope-based access control layer to all 81 existing API routes. Sits alongside existing session-cookie auth (does not replace it). What this PR does: - Audits every existing route and documents required scope per route (see documentation/auth/route-scope-audit.md) - Adds classes/auth/scope.php with 10 scope constants and role→scope defaults - Adds classes/auth/scope_middleware.php with requireScope/requireAnyScope/requireRole - Applies require*() calls to all 81 existing routes - Adds ScopeMiddlewareTest (unit, 178 lines) and RouteScopeTest (integration, 212 lines) Coexistence note: This branch's classes/auth/scope.php is a stub that will be replaced by classes/auth/scope_registry.php (from TRU-145 / PR #396) when that PR merges first. The two have compatible APIs. Refs: TRU-149
384 lines
14 KiB
PHP
384 lines
14 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\licenseplaterecognizer;
|
|
use classes\response;
|
|
use traits\route_t;
|
|
|
|
use app\auth\Scope;
|
|
use app\auth\ScopeMiddleware;
|
|
|
|
class moduleScannerRoute
|
|
{
|
|
use route_t;
|
|
|
|
private const LPR_IMAGE_UPLOAD_FIELD = 'image';
|
|
private const LPR_IMAGE_UPLOAD_MAX_BYTES = 4194304;
|
|
private const LPR_CLIENT_CAPTURE_MS_FIELD = 'client_capture_ms';
|
|
private const LPR_CLIENT_CAPTURE_MAX_MS = 10000;
|
|
private const LPR_CLIENT_DRAW_MS_FIELD = 'client_draw_ms';
|
|
private const LPR_CLIENT_ENCODE_MS_FIELD = 'client_encode_ms';
|
|
private const LPR_CLIENT_FRAME_WIDTH_FIELD = 'client_frame_width';
|
|
private const LPR_CLIENT_FRAME_HEIGHT_FIELD = 'client_frame_height';
|
|
private const LPR_CLIENT_FRAME_BYTES_FIELD = 'client_frame_bytes';
|
|
private const LPR_CLIENT_FRAME_MAX_DIMENSION = 4096;
|
|
private const LPR_CLIENT_PREFLIGHT_MS_FIELD = 'client_preflight_ms';
|
|
private const LPR_CLIENT_VISUAL_FINGERPRINT_MS_FIELD = 'client_visual_fingerprint_ms';
|
|
private const LPR_CLIENT_CAPTURE_MS_HEADER = 'HTTP_X_LPR_CLIENT_CAPTURE_MS';
|
|
private const LPR_CLIENT_DRAW_MS_HEADER = 'HTTP_X_LPR_CLIENT_DRAW_MS';
|
|
private const LPR_CLIENT_ENCODE_MS_HEADER = 'HTTP_X_LPR_CLIENT_ENCODE_MS';
|
|
private const LPR_CLIENT_FRAME_WIDTH_HEADER = 'HTTP_X_LPR_CLIENT_FRAME_WIDTH';
|
|
private const LPR_CLIENT_FRAME_HEIGHT_HEADER = 'HTTP_X_LPR_CLIENT_FRAME_HEIGHT';
|
|
private const LPR_CLIENT_FRAME_BYTES_HEADER = 'HTTP_X_LPR_CLIENT_FRAME_BYTES';
|
|
private const LPR_CLIENT_PREFLIGHT_MS_HEADER = 'HTTP_X_LPR_CLIENT_PREFLIGHT_MS';
|
|
private const LPR_CLIENT_VISUAL_FINGERPRINT_MS_HEADER = 'HTTP_X_LPR_CLIENT_VISUAL_FINGERPRINT_MS';
|
|
|
|
public function run(): void
|
|
{
|
|
global /** @var response $response */
|
|
$router, $response;
|
|
/** Modules > Scanner > License Plate Recognition > POST */
|
|
$this->post('/modules/scanner/lpr', function () {
|
|
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/scanner/lpr');
|
|
global $response;
|
|
$route_started_at = microtime(true);
|
|
$image_upload = self::getLPRImageUpload();
|
|
$raw_image_upload = $image_upload === null ? self::getLPRRawImageUpload() : null;
|
|
$client_timings = self::getLPRClientTimings();
|
|
$base64_image = null;
|
|
//self::requirePermission('modules_scanner_lpr');
|
|
if ($image_upload === null && $raw_image_upload === null) {
|
|
$base64_image = self::getParameter('base64_image');
|
|
if (!is_string($base64_image) || trim($base64_image) === '') {
|
|
$response->error('Image is required.');
|
|
}
|
|
}
|
|
$recognizer = new licenseplaterecognizer(false);
|
|
try {
|
|
if ($image_upload !== null) {
|
|
$lpr_result = $recognizer->licenseplaterecognizerUploadFile($image_upload['path'], $image_upload['mime_type']);
|
|
} elseif ($raw_image_upload !== null) {
|
|
$lpr_result = $recognizer->licenseplaterecognizerUploadUncached($raw_image_upload['data'], $raw_image_upload['mime_type']);
|
|
} else {
|
|
$lpr_result = $recognizer->licenseplaterecognizer((string)$base64_image);
|
|
}
|
|
} finally {
|
|
self::sendLPRServerTiming(array_merge($client_timings, $recognizer->getLastTimings()), $route_started_at);
|
|
}
|
|
if ($lpr_result['success']) {
|
|
// Set the LICENSE_PLATE_NUMBER to uppercase and remove spaces.
|
|
$lpr_result['license_plate_number'] = strtoupper(str_replace(' ', '', $lpr_result['license_plate_number']));
|
|
// Make sure the scanned plate is more than 3 characters long.
|
|
$scannedPlateIsTooShort = strlen($lpr_result['license_plate_number']) <= 3;
|
|
// If the confidence is below 90%, treat it as a recoverable scanner miss.
|
|
if (isset($lpr_result['confidence']) && $lpr_result['confidence'] < 0.9 && !$scannedPlateIsTooShort) {
|
|
$response->response(false, [
|
|
'message' => 'License plate recognition confidence too low.',
|
|
'reason' => 'low_confidence_license_plate',
|
|
'confidence' => $lpr_result['confidence'],
|
|
'license_plate_number' => $lpr_result['license_plate_number'],
|
|
], 200);
|
|
}
|
|
// Success
|
|
$response->success(['success' => true, 'license_plate_number' => $lpr_result['license_plate_number']]);
|
|
} else {
|
|
$response->response(false, [
|
|
'message' => $lpr_result['message'] ?? 'No license plate detected.',
|
|
'reason' => 'no_license_plate_detected',
|
|
], 200);
|
|
}
|
|
},
|
|
[
|
|
'modules_scanner_lpr' => 'License Plate Recognition',
|
|
]
|
|
);
|
|
}
|
|
|
|
private static function getLPRImageUpload(): ?array
|
|
{
|
|
global $response;
|
|
|
|
if (!isset($_FILES[self::LPR_IMAGE_UPLOAD_FIELD]) || !is_array($_FILES[self::LPR_IMAGE_UPLOAD_FIELD])) {
|
|
return null;
|
|
}
|
|
|
|
$file = $_FILES[self::LPR_IMAGE_UPLOAD_FIELD];
|
|
if (is_array($file['error'] ?? null)) {
|
|
$response->error('Only one image can be uploaded.', 400);
|
|
}
|
|
|
|
$upload_error = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE);
|
|
if ($upload_error === UPLOAD_ERR_NO_FILE) {
|
|
return null;
|
|
}
|
|
|
|
if ($upload_error !== UPLOAD_ERR_OK) {
|
|
$response->error('Image upload failed.', 400);
|
|
}
|
|
|
|
$size = (int)($file['size'] ?? 0);
|
|
if ($size <= 0) {
|
|
$response->error('Image upload is empty.', 400);
|
|
}
|
|
|
|
if ($size > self::LPR_IMAGE_UPLOAD_MAX_BYTES) {
|
|
$response->error('Image upload is too large.', 413);
|
|
}
|
|
|
|
$tmp_name = (string)($file['tmp_name'] ?? '');
|
|
if ($tmp_name === '' || !is_uploaded_file($tmp_name)) {
|
|
$response->error('Image upload is invalid.', 400);
|
|
}
|
|
|
|
if (!is_readable($tmp_name)) {
|
|
$response->error('Image upload could not be read.', 400);
|
|
}
|
|
|
|
$mime_type = self::detectLPRImageMimeType($file, $tmp_name);
|
|
if (!str_starts_with($mime_type, 'image/')) {
|
|
$response->error('Image upload must be an image.', 400);
|
|
}
|
|
|
|
return [
|
|
'path' => $tmp_name,
|
|
'mime_type' => $mime_type,
|
|
];
|
|
}
|
|
|
|
private static function getLPRRawImageUpload(): ?array
|
|
{
|
|
global $response;
|
|
|
|
$mime_type = self::getRequestContentType();
|
|
if (!str_starts_with($mime_type, 'image/')) {
|
|
return null;
|
|
}
|
|
|
|
$content_length = isset($_SERVER['CONTENT_LENGTH']) && is_numeric($_SERVER['CONTENT_LENGTH'])
|
|
? (int)$_SERVER['CONTENT_LENGTH']
|
|
: null;
|
|
if ($content_length !== null && $content_length > self::LPR_IMAGE_UPLOAD_MAX_BYTES) {
|
|
$response->error('Image upload is too large.', 413);
|
|
}
|
|
|
|
$image_data = file_get_contents('php://input');
|
|
if (!is_string($image_data) || $image_data === '') {
|
|
$response->error('Image upload is empty.', 400);
|
|
}
|
|
|
|
if (strlen($image_data) > self::LPR_IMAGE_UPLOAD_MAX_BYTES) {
|
|
$response->error('Image upload is too large.', 413);
|
|
}
|
|
|
|
return [
|
|
'data' => $image_data,
|
|
'mime_type' => $mime_type,
|
|
];
|
|
}
|
|
|
|
private static function getRequestContentType(): string
|
|
{
|
|
$content_type = (string)($_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? '');
|
|
$content_type = strtolower(trim(explode(';', $content_type, 2)[0] ?? ''));
|
|
|
|
return $content_type;
|
|
}
|
|
|
|
private static function detectLPRImageMimeType(array $file, string $tmp_name): string
|
|
{
|
|
$mime_type = trim((string)($file['type'] ?? ''));
|
|
if ($mime_type !== '') {
|
|
return $mime_type;
|
|
}
|
|
|
|
if (class_exists(\finfo::class)) {
|
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
|
$detected = $finfo->file($tmp_name);
|
|
if (is_string($detected) && $detected !== '') {
|
|
return $detected;
|
|
}
|
|
}
|
|
|
|
return 'image/jpeg';
|
|
}
|
|
|
|
private static function getLPRClientTimings(): array
|
|
{
|
|
$timings = [];
|
|
$client_capture_ms = self::getNumericClientField(
|
|
self::LPR_CLIENT_CAPTURE_MS_FIELD,
|
|
self::LPR_CLIENT_CAPTURE_MS_HEADER,
|
|
0,
|
|
self::LPR_CLIENT_CAPTURE_MAX_MS
|
|
);
|
|
if ($client_capture_ms !== null) {
|
|
$timings['client_capture'] = $client_capture_ms;
|
|
}
|
|
|
|
$client_preflight_ms = self::getNumericClientField(
|
|
self::LPR_CLIENT_PREFLIGHT_MS_FIELD,
|
|
self::LPR_CLIENT_PREFLIGHT_MS_HEADER,
|
|
0,
|
|
self::LPR_CLIENT_CAPTURE_MAX_MS
|
|
);
|
|
if ($client_preflight_ms !== null) {
|
|
$timings['client_preflight'] = $client_preflight_ms;
|
|
}
|
|
|
|
$client_draw_ms = self::getNumericClientField(
|
|
self::LPR_CLIENT_DRAW_MS_FIELD,
|
|
self::LPR_CLIENT_DRAW_MS_HEADER,
|
|
0,
|
|
self::LPR_CLIENT_CAPTURE_MAX_MS
|
|
);
|
|
if ($client_draw_ms !== null) {
|
|
$timings['client_draw'] = $client_draw_ms;
|
|
}
|
|
|
|
$client_encode_ms = self::getNumericClientField(
|
|
self::LPR_CLIENT_ENCODE_MS_FIELD,
|
|
self::LPR_CLIENT_ENCODE_MS_HEADER,
|
|
0,
|
|
self::LPR_CLIENT_CAPTURE_MAX_MS
|
|
);
|
|
if ($client_encode_ms !== null) {
|
|
$timings['client_encode'] = $client_encode_ms;
|
|
}
|
|
|
|
$client_visual_fingerprint_ms = self::getNumericClientField(
|
|
self::LPR_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
|
|
self::LPR_CLIENT_VISUAL_FINGERPRINT_MS_HEADER,
|
|
0,
|
|
self::LPR_CLIENT_CAPTURE_MAX_MS
|
|
);
|
|
if ($client_visual_fingerprint_ms !== null) {
|
|
$timings['client_visual_fingerprint'] = $client_visual_fingerprint_ms;
|
|
}
|
|
|
|
$client_frame_width = self::getNumericClientField(
|
|
self::LPR_CLIENT_FRAME_WIDTH_FIELD,
|
|
self::LPR_CLIENT_FRAME_WIDTH_HEADER,
|
|
1,
|
|
self::LPR_CLIENT_FRAME_MAX_DIMENSION
|
|
);
|
|
if ($client_frame_width !== null) {
|
|
$timings['client_frame_width'] = $client_frame_width;
|
|
}
|
|
|
|
$client_frame_height = self::getNumericClientField(
|
|
self::LPR_CLIENT_FRAME_HEIGHT_FIELD,
|
|
self::LPR_CLIENT_FRAME_HEIGHT_HEADER,
|
|
1,
|
|
self::LPR_CLIENT_FRAME_MAX_DIMENSION
|
|
);
|
|
if ($client_frame_height !== null) {
|
|
$timings['client_frame_height'] = $client_frame_height;
|
|
}
|
|
|
|
$client_frame_bytes = self::getNumericClientField(
|
|
self::LPR_CLIENT_FRAME_BYTES_FIELD,
|
|
self::LPR_CLIENT_FRAME_BYTES_HEADER,
|
|
1,
|
|
self::LPR_IMAGE_UPLOAD_MAX_BYTES
|
|
);
|
|
if ($client_frame_bytes !== null) {
|
|
$timings['client_frame_bytes'] = $client_frame_bytes;
|
|
}
|
|
|
|
return $timings;
|
|
}
|
|
|
|
private static function getNumericClientField(string $field, string $server_header, float $min, float $max): ?float
|
|
{
|
|
$value = $_GET[$field] ?? null;
|
|
if ($value === null) {
|
|
$value = $_POST[$field] ?? null;
|
|
}
|
|
if ($value === null) {
|
|
$value = $_SERVER[$server_header] ?? null;
|
|
}
|
|
if (is_array($value) || !is_numeric($value)) {
|
|
return null;
|
|
}
|
|
|
|
$value = (float)$value;
|
|
if ($value < $min || $value > $max) {
|
|
return null;
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private static function sendLPRServerTiming(array $timings, float $route_started_at): void
|
|
{
|
|
if (headers_sent()) {
|
|
return;
|
|
}
|
|
|
|
$parts = [];
|
|
$timings['route_total'] = max(0, (microtime(true) - $route_started_at) * 1000);
|
|
$timings['local'] = self::getLPRLocalDuration($timings);
|
|
if (isset($_SERVER['REQUEST_TIME_FLOAT']) && is_numeric($_SERVER['REQUEST_TIME_FLOAT'])) {
|
|
$timings['request_total'] = max(0, (microtime(true) - (float)$_SERVER['REQUEST_TIME_FLOAT']) * 1000);
|
|
}
|
|
|
|
foreach ([
|
|
'client_capture',
|
|
'client_preflight',
|
|
'client_visual_fingerprint',
|
|
'client_draw',
|
|
'client_encode',
|
|
'client_frame_width',
|
|
'client_frame_height',
|
|
'client_frame_bytes',
|
|
'config',
|
|
'cache',
|
|
'cache_hit',
|
|
'cache_miss',
|
|
'local',
|
|
'payload',
|
|
'upstream_dns',
|
|
'upstream_connect',
|
|
'upstream_tls',
|
|
'upstream_pretransfer',
|
|
'upstream_ttfb',
|
|
'upstream_total',
|
|
'upstream_processing',
|
|
'upstream',
|
|
'parse',
|
|
'total',
|
|
] as $name) {
|
|
if (!isset($timings[$name]) || !is_numeric($timings[$name])) {
|
|
continue;
|
|
}
|
|
|
|
$parts[] = sprintf('lpr_%s;dur=%.3F', $name, (float)$timings[$name]);
|
|
}
|
|
foreach (['route_total', 'request_total'] as $name) {
|
|
if (!isset($timings[$name]) || !is_numeric($timings[$name])) {
|
|
continue;
|
|
}
|
|
|
|
$parts[] = sprintf('lpr_%s;dur=%.3F', $name, (float)$timings[$name]);
|
|
}
|
|
|
|
if ($parts !== []) {
|
|
header('Server-Timing: ' . implode(', ', $parts));
|
|
}
|
|
}
|
|
|
|
private static function getLPRLocalDuration(array $timings): float
|
|
{
|
|
$upstream = null;
|
|
foreach (['upstream', 'upstream_total'] as $name) {
|
|
if (isset($timings[$name]) && is_numeric($timings[$name])) {
|
|
$upstream = max(0, (float)$timings[$name]);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return max(0, (float)$timings['route_total'] - ($upstream ?? 0));
|
|
}
|
|
}
|