Files
api/services/nginx/app/routes/moduleScannerRoute.php
T
OpenClaw 51a87655d6 feat(auth): add scope-based access control to all existing routes (TRU-149)
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
2026-08-17 11:43:13 +00:00

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));
}
}