892 lines
32 KiB
PHP
892 lines
32 KiB
PHP
<?php
|
|
|
|
namespace traits;
|
|
|
|
use classes\authentication;
|
|
use classes\permission_node;
|
|
use classes\recaptcha;
|
|
use classes\response;
|
|
use Exception;
|
|
use modules\subusers\helpers\subusers_permission_node_key;
|
|
use modules\subusers\classes\subuser_user_grant;
|
|
use objects\logs_o;
|
|
|
|
trait route_t
|
|
{
|
|
protected array $permissions = [];
|
|
private string $route;
|
|
/**
|
|
* The route template (e.g., "/account/security/passkeys/{id}") of the currently matched route.
|
|
* This is populated just before the route callback is executed.
|
|
*/
|
|
private ?string $__current_route_template = null;
|
|
/**
|
|
* Lightweight per-request caches to avoid repeated DB/auth checks during a single request lifecycle.
|
|
*/
|
|
protected static array $__perm_user_cache = [];
|
|
protected static array $__perm_subuser_grant_cache = [];
|
|
|
|
public function __construct()
|
|
{
|
|
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
|
|
$this->route = is_string($requestUri) && $requestUri !== '' ? $requestUri : '/';
|
|
}
|
|
|
|
public function run(): void
|
|
{
|
|
// Add the routes here
|
|
}
|
|
|
|
/**
|
|
* Is authenticated?
|
|
* This function returns a boolean indicating if the user is authenticated
|
|
* @return bool
|
|
*/
|
|
public function isAuthenticated(): bool
|
|
{
|
|
global $response;
|
|
try {
|
|
$user = (new authentication())->get_user();
|
|
// If there is no user, return false
|
|
if (!$user) {
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (Exception $e) {
|
|
$response->error($e->getMessage(), 400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Require type
|
|
* @param mixed $value
|
|
* @param string $type
|
|
* @return bool
|
|
*/
|
|
public function requireType(mixed $value, string $type): bool
|
|
{
|
|
// Get the type of the value
|
|
$value_type = gettype($value);
|
|
// If the type is Array, or Object, json_decode the value to check if it is a valid JSON
|
|
if ($type === 'array' || $type === 'object') {
|
|
if (is_array($value)) {
|
|
$value = json_encode($value);
|
|
} elseif (is_object($value)) {
|
|
$value = json_encode($value);
|
|
}
|
|
$value = json_decode($value, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
global $response;
|
|
$response->error('Invalid JSON. Value: ' . $value, 400);
|
|
}
|
|
$value_type = gettype($value);
|
|
}
|
|
// Check if the value is of the required type
|
|
if ($value_type !== $type) {
|
|
global $response;
|
|
$response->error('Invalid type. Expected: ' . $type . ' Got: ' . $value_type . ' Value: ' . (is_array($value) ? json_encode($value) : $value), 400);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Define a permission for the route
|
|
* @description Used to standardize permission definitions and link them to subusers permission nodes if needed, this is used in the route definitions to define the permissions required for the route, and to link them to the subusers permission nodes for easier management in the subusers module
|
|
* @param string $permission The permission to define (Example: 'modules_motorapi_lookup')
|
|
* @param subusers_permission_node_key|null $subusers_permission_node_key The subusers permission node key to link the permission to (Example: subusers_permission_node_key::MOTORAPI_LOOKUP)
|
|
* @return permission_node
|
|
* @throws Exception
|
|
*/
|
|
public function definePermission(string $permission, subusers_permission_node_key|null $subusers_permission_node_key = null): permission_node
|
|
{
|
|
if ($permission === '') {
|
|
throw new Exception('Permission cannot be empty');
|
|
}
|
|
if ($subusers_permission_node_key !== null && !in_array($subusers_permission_node_key, subusers_permission_node_key::cases())) {
|
|
throw new Exception('Invalid subusers_permission_node_key');
|
|
}
|
|
return permission_node::create($permission, $subusers_permission_node_key);
|
|
}
|
|
|
|
/**
|
|
* Require a value to be in the given array
|
|
* @param mixed $value The value to check
|
|
* @param array $array The array to check against
|
|
* @return bool
|
|
* @throws Exception If the value is not in the array
|
|
*/
|
|
public function requireInArray(mixed $value, array $array): bool
|
|
{
|
|
global $response;
|
|
// Check if the value is in the array
|
|
if (!in_array($value, $array)) {
|
|
$response->error(json_encode([
|
|
'message' => 'Unexpected value',
|
|
'expected' => $array,
|
|
'got' => $value,
|
|
'type' => gettype($value)
|
|
]), 400);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public function requireSameLength(mixed $arg1, mixed $arg2): bool
|
|
{
|
|
global $response;
|
|
if (strlen($arg1) !== strlen($arg2)) {
|
|
$response->error('Invalid length. Expected: ' . strlen($arg2) . ' Got: ' . strlen($arg1), 400);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Require parameter to be one of the given types
|
|
* @param mixed $value The value to check
|
|
* @param array $types The types to check against
|
|
* @return bool
|
|
*/
|
|
public function requireTypeIn(mixed $value, array $types): bool
|
|
{
|
|
// Get the type of the value
|
|
$value_type = gettype($value);
|
|
// Check if the value is of the required type
|
|
if (!in_array($value_type, $types)) {
|
|
global $response;
|
|
$response->error('Invalid type. Expected: ' . implode(', ', $types) . ' Got: ' . $value_type . ' Value: ' . $value, 400);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* TYPE_ARRAY
|
|
* @note This is used to check if the value is an array, this is used in routes to validate input
|
|
* @return string
|
|
*/
|
|
public function TYPE_ARRAY(): string
|
|
{
|
|
return 'array';
|
|
}
|
|
|
|
/**
|
|
* TYPE_NULL
|
|
* @note This is used to check if the value is null, this is used in routes to validate input
|
|
* @return string
|
|
*/
|
|
public function TYPE_NULL(): string
|
|
{
|
|
return 'NULL';
|
|
}
|
|
|
|
/**
|
|
* The default date format (Y-m-d) Example: 2023-01-01
|
|
* @note This is used to format dates in the API
|
|
* @return string
|
|
*/
|
|
public function FORMAT_DATE(): string
|
|
{
|
|
return 'Y-m-d';
|
|
}
|
|
|
|
/**
|
|
* TYPE_BOOL
|
|
* @note This is used to check if the value is a boolean, this is used in routes to validate input
|
|
* @return string
|
|
*/
|
|
public function TYPE_BOOL(): string
|
|
{
|
|
return 'boolean';
|
|
}
|
|
|
|
/**
|
|
* @param string $date
|
|
* @param string $format
|
|
* @return void
|
|
*/
|
|
public function requireDateFormat(string $date, string $format): void
|
|
{
|
|
global $response;
|
|
$d = \DateTime::createFromFormat($format, $date);
|
|
if (!$d || $d->format($format) !== $date) {
|
|
$response->error('Invalid date format. Expected: ' . $format . ' Got: ' . $date, 400);
|
|
}
|
|
}
|
|
|
|
public function requireMinValue(int $value, int $min): void
|
|
{
|
|
global $response;
|
|
if ($value < $min) {
|
|
$response->error('Parameter must be at least ' . $min, 400);
|
|
}
|
|
}
|
|
|
|
public function type_int(): string
|
|
{
|
|
return 'integer';
|
|
}
|
|
|
|
public function type_string(): string
|
|
{
|
|
return 'string';
|
|
}
|
|
|
|
public function requireParameters(array $parameters): void
|
|
{
|
|
global $response;
|
|
$missing_parameters = [];
|
|
// Check if all required parameters are set
|
|
foreach ( $parameters as $parameter ) {
|
|
if (!$response->getRequestParameter($parameter) && !$response->isRequestParameterSet($parameter)) {
|
|
$missing_parameters[] = $parameter;
|
|
}
|
|
}
|
|
if (count($missing_parameters) > 0) {
|
|
$response->error('Missing required parameters: ' . implode(', ', $missing_parameters), 400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Require the max value of the given value
|
|
* @param int $value - The value to check
|
|
* @param int $max - The max value
|
|
*/
|
|
public function requireMaxValue(int $value, int $max): void
|
|
{
|
|
global
|
|
/** @var response $response */
|
|
$response;
|
|
if ($value > $max) {
|
|
$response->error('Parameter must be at most ' . $max, 400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Require department access
|
|
* @note This checks if the user has access to the department by checking if the user has the permission department_access_{department} (_{permission} if provided)
|
|
* @param string $department
|
|
* @param string|null $permission
|
|
*/
|
|
public function requireDepartmentAccess(string $department, string|null $permission = null): void
|
|
{
|
|
self::requirePermission('department_access_' . $department . ($permission ? '_' . $permission : ''));
|
|
}
|
|
|
|
/**
|
|
* Has department access?
|
|
* @note This checks if the user has access to the department by checking if the user has the permission department_access_{department} (_{permission} if provided)
|
|
* @param string $department
|
|
* @param string|null $permission
|
|
* @return bool
|
|
*/
|
|
public function hasDepartmentAccess(string $department, string|null $permission = null): bool
|
|
{
|
|
return self::hasPermission('department_access_' . $department . ($permission ? '_' . $permission : ''));
|
|
}
|
|
|
|
/**
|
|
* Get parameters as an array from the request
|
|
* @return array
|
|
*/
|
|
public function getParametersAsArray(): array
|
|
{
|
|
global $response;
|
|
return $response->getAllRequestParameters();
|
|
}
|
|
|
|
/**
|
|
* Resolve customer number for a subuser permission context.
|
|
* Order of precedence:
|
|
* - Explicit `$customer_number` argument if provided
|
|
* - Main authenticated user context (if available)
|
|
*
|
|
* Important: never derive this value from caller-controlled request
|
|
* headers/parameters at this layer. Route handlers must pass a trusted
|
|
* customer number that is bound to the target resource when needed.
|
|
*/
|
|
private function resolveCustomerNumberForSubuser(authentication $auth, ?int $customer_number = null): ?int
|
|
{
|
|
if ($customer_number !== null) {
|
|
return (int)$customer_number;
|
|
}
|
|
$user_ctx = $auth->get_user();
|
|
if ($user_ctx !== false && isset($user_ctx->customer_number)) {
|
|
return (int)$user_ctx->customer_number->value();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Convert a permission definition to its canonical string key.
|
|
*/
|
|
private function permissionKey(string|permission_node $permission): string
|
|
{
|
|
return $permission instanceof permission_node
|
|
? (string)$permission->permission
|
|
: (string)$permission;
|
|
}
|
|
|
|
/**
|
|
* Normalize and de-duplicate permission keys for forbidden responses.
|
|
* Accepts string keys and permission_node definitions.
|
|
*
|
|
* @param array<int, string|permission_node> $permissions
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalizePermissionKeys(array $permissions): array
|
|
{
|
|
$keys = [];
|
|
foreach ($permissions as $permission) {
|
|
if ($permission instanceof permission_node) {
|
|
$key = trim((string)$permission->permission);
|
|
} elseif (is_string($permission)) {
|
|
$key = trim($permission);
|
|
} else {
|
|
continue;
|
|
}
|
|
if ($key !== '') {
|
|
$keys[] = $key;
|
|
}
|
|
}
|
|
return array_values(array_unique($keys));
|
|
}
|
|
|
|
/**
|
|
* Emit standardized forbidden response payload with missing permission keys.
|
|
* Extracted to allow focused unit tests by overriding this method.
|
|
*
|
|
* @param array<int, string|permission_node> $permissions
|
|
*/
|
|
protected function emitForbidden(array $permissions): void
|
|
{
|
|
global $response;
|
|
$response->forbidden($this->normalizePermissionKeys($permissions));
|
|
}
|
|
|
|
/**
|
|
* Emit a forbidden response for missing department scope access.
|
|
* Optionally include bypass permissions if they are relevant and missing.
|
|
*
|
|
* @param int $departmentId
|
|
* @param array<int, string|permission_node> $optionalBypassPermissions
|
|
*/
|
|
public function forbidDepartmentAccess(int $departmentId, array $optionalBypassPermissions = []): void
|
|
{
|
|
$missing = ['department_access_' . $departmentId];
|
|
foreach ($optionalBypassPermissions as $permission) {
|
|
if (!$this->hasPermission($permission)) {
|
|
$missing[] = $permission;
|
|
}
|
|
}
|
|
$this->emitForbidden($missing);
|
|
}
|
|
|
|
/**
|
|
* Centralized permission evaluation used by both requirePermission and hasPermission.
|
|
* - Honors subusers permission nodes without falling back to classic user permissions when a node is defined.
|
|
* - Supports explicit customer_number overrides and auto-detection fallback.
|
|
* - Uses simple per-request caches for efficiency.
|
|
*/
|
|
private function evaluatePermission(string|permission_node $permission, ?int $customer_number, bool $throwOnDeny): bool
|
|
{
|
|
global $response;
|
|
try {
|
|
$auth = new authentication();
|
|
|
|
// If permission is a node and a subuser is authenticated, evaluate node-based grant first
|
|
$subuser = $auth->get_subuser();
|
|
if ($subuser !== false && $permission instanceof permission_node && $permission->subusers_node_key !== null) {
|
|
$resolvedCustomer = $this->resolveCustomerNumberForSubuser($auth, $customer_number);
|
|
if ($resolvedCustomer === null) {
|
|
(new logs_o())->add('global', 'global', 1, $subuser->id ?? 0, 'PERMISSION_DENIED', 'Missing customer context for subuser permission evaluation: ' . $permission->permission);
|
|
if ($throwOnDeny) {
|
|
$this->emitForbidden([$permission]);
|
|
}
|
|
return false;
|
|
}
|
|
// Expose resolved target customer in response meta for subuser requests
|
|
$response->add_meta('target_customer_number', (int)$resolvedCustomer);
|
|
|
|
$cacheKey = "subuser:{$subuser->id}:{$resolvedCustomer}:{$permission->subusers_node_key->name}";
|
|
if (!isset(self::$__perm_subuser_grant_cache[$cacheKey])) {
|
|
$cached = null;
|
|
if (defined('redis')) {
|
|
$cached = redis->get_permission($cacheKey);
|
|
}
|
|
if ($cached !== null) {
|
|
self::$__perm_subuser_grant_cache[$cacheKey] = $cached;
|
|
} else {
|
|
$subuser_has_permission = $subuser->hasPermission($permission->subusers_node_key, (int)$resolvedCustomer);
|
|
if (defined('redis')) {
|
|
redis->cache_permission($cacheKey, $subuser_has_permission);
|
|
}
|
|
self::$__perm_subuser_grant_cache[$cacheKey] = $subuser_has_permission;
|
|
}
|
|
}
|
|
$subuser_has_permission = self::$__perm_subuser_grant_cache[$cacheKey];
|
|
|
|
if (!$subuser_has_permission && $throwOnDeny) {
|
|
(new logs_o())->add('global', 'global', 1, $subuser->id ?? 0, 'PERMISSION_DENIED', 'Permission denied via subuser node: ' . $permission->permission . ' (Node: ' . $permission->subusers_node_key->name . ', Customer: ' . $resolvedCustomer . ')');
|
|
$this->emitForbidden([$permission]);
|
|
}
|
|
return $subuser_has_permission;
|
|
}
|
|
|
|
// Fallback to classic user permission check (or if permission is plain string)
|
|
$user = $auth->get_user();
|
|
if (!$user) {
|
|
(new logs_o())->add('global', 'global', 1, 0, 'AUTHENTICATION_FAILED', 'Authentication failed. Invalid, or missing token');
|
|
if ($throwOnDeny) {
|
|
$response->error('Authentication failed. Invalid or missing token.', 401);
|
|
}
|
|
return false;
|
|
}
|
|
$perm_string = $permission instanceof permission_node ? $permission->permission : $permission;
|
|
$userCacheKey = (string)$user->id . ':' . $perm_string;
|
|
$redisKey = "user:{$user->id}:{$perm_string}";
|
|
|
|
if (!isset(self::$__perm_user_cache[$userCacheKey])) {
|
|
$cached = null;
|
|
if (defined('redis')) {
|
|
$cached = redis->get_permission($redisKey);
|
|
}
|
|
if ($cached !== null) {
|
|
self::$__perm_user_cache[$userCacheKey] = $cached;
|
|
} else {
|
|
$allowed = (bool)$user->hasPermission($perm_string);
|
|
if (defined('redis')) {
|
|
redis->cache_permission($redisKey, $allowed);
|
|
}
|
|
self::$__perm_user_cache[$userCacheKey] = $allowed;
|
|
}
|
|
}
|
|
$allowed = (bool)self::$__perm_user_cache[$userCacheKey];
|
|
|
|
if (!$allowed && $throwOnDeny) {
|
|
(new logs_o())->add('global', 'global', 1, $user->id, 'PERMISSION_DENIED', 'Permission denied. Missing permission: ' . $perm_string);
|
|
$this->emitForbidden([$perm_string]);
|
|
}
|
|
return $allowed;
|
|
} catch (Exception $e) {
|
|
$response->error($e->getMessage(), 400);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Require permission
|
|
* @param string|permission_node $permission
|
|
* @return bool
|
|
*/
|
|
public function requirePermission(string|permission_node $permission): bool
|
|
{
|
|
return $this->evaluatePermission($permission, null, true);
|
|
}
|
|
|
|
/**
|
|
* Check if the user has a permission
|
|
* @param string|permission_node $permission
|
|
* @return bool
|
|
*/
|
|
public function hasPermission(string|permission_node $permission, int $customer_number = null): bool
|
|
{
|
|
return $this->evaluatePermission($permission, $customer_number, false);
|
|
}
|
|
|
|
/**
|
|
* Determine if the current principal (classic user or subuser) acts on their own customer context.
|
|
* - For classic users: compares against the authenticated user's customer_number
|
|
* - For subusers: compares against X-Customer-Number target header
|
|
*/
|
|
public function isOwnCustomerContext(int $targetCustomerNumber): bool
|
|
{
|
|
try {
|
|
$auth = new authentication();
|
|
$sub = $auth->get_subuser();
|
|
if ($sub !== false) {
|
|
$tgt = $auth->get_subuser_customer_number_target();
|
|
return ((int)$tgt === (int)$targetCustomerNumber);
|
|
}
|
|
$user = $auth->get_user();
|
|
if ($user !== false) {
|
|
return ((int)$user->customer_number->value() === (int)$targetCustomerNumber);
|
|
}
|
|
} catch (Exception) {
|
|
// fall through
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Resolve the most relevant customer number for the current request context.
|
|
* Priority: classic user -> X-Customer-Number header -> query/body customer_number
|
|
*/
|
|
public function resolveEffectiveCustomerNumber(): ?int
|
|
{
|
|
try {
|
|
$auth = new authentication();
|
|
$user = $auth->get_user();
|
|
if ($user !== false && isset($user->customer_number)) {
|
|
return (int)$user->customer_number->value();
|
|
}
|
|
$sub = $auth->get_subuser();
|
|
if ($sub !== false) {
|
|
$tgt = $auth->get_subuser_customer_number_target();
|
|
if ($tgt !== false && $tgt !== null) return (int)$tgt;
|
|
}
|
|
// Fallbacks
|
|
if (isset($_GET['customer_number'])) return (int)$_GET['customer_number'];
|
|
if (isset($_POST['customer_number'])) return (int)$_POST['customer_number'];
|
|
} catch (Exception) {
|
|
// ignore
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Common pattern helper: allow via own-permission (with optional guard) or via department/admin permission.
|
|
* If neither path allows, respond with 403 and provided message.
|
|
*
|
|
* @param string|permission_node $permissionOwn Own-scope permission (often linked to a subuser node)
|
|
* @param string|permission_node $permissionOther Department/admin permission
|
|
* @param int|null $targetCustomerNumber Customer number to validate "own" scope against (null means list context)
|
|
* @param int|null $departmentId Department id for admin path (will be validated when provided)
|
|
* @param callable|null $ownGuard Optional additional guard for own path. Return true to allow, false to deny own-path.
|
|
* @param string|null $denyMessage Message to return on deny (defaults to generic)
|
|
* @return bool True if access is allowed (also throws on deny)
|
|
*/
|
|
public function allowOwnOrDepartmentAccess(
|
|
string|permission_node $permissionOwn,
|
|
string|permission_node $permissionOther,
|
|
?int $targetCustomerNumber,
|
|
?int $departmentId,
|
|
?callable $ownGuard = null,
|
|
?string $denyMessage = null
|
|
): bool {
|
|
global $response;
|
|
$allowed = false;
|
|
$hasOwn = $this->hasPermission($permissionOwn);
|
|
$hasOther = $this->hasPermission($permissionOther);
|
|
|
|
// Try own path first
|
|
if ($hasOwn) {
|
|
$isOwnContext = ($targetCustomerNumber === null) ? true : $this->isOwnCustomerContext((int)$targetCustomerNumber);
|
|
if ($isOwnContext) {
|
|
$guardOk = $ownGuard ? (bool)call_user_func($ownGuard) : true;
|
|
if ($guardOk) {
|
|
$allowed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to department/admin path
|
|
if (!$allowed && $hasOther) {
|
|
if ($departmentId !== null) {
|
|
$this->requireDepartmentAccess((int)$departmentId);
|
|
}
|
|
$allowed = true;
|
|
}
|
|
|
|
if (!$allowed) {
|
|
$missingPermissions = [];
|
|
if (!$hasOwn) {
|
|
$missingPermissions[] = $permissionOwn;
|
|
}
|
|
if (!$hasOther) {
|
|
$missingPermissions[] = $permissionOther;
|
|
}
|
|
// In own-scope failures (wrong customer/guard failure), report missing elevated permission only.
|
|
if ($hasOwn && !$hasOther) {
|
|
$missingPermissions[] = $permissionOther;
|
|
}
|
|
if (count($missingPermissions) === 0) {
|
|
$missingPermissions[] = $permissionOther;
|
|
}
|
|
$this->emitForbidden($missingPermissions);
|
|
}
|
|
return $allowed;
|
|
}
|
|
|
|
/**
|
|
* Require parameter to be a positive integer
|
|
* @param int $value The value to check
|
|
* @param string|null $parameter The name of the parameter
|
|
* @return void
|
|
*/
|
|
public function requireParameterIntPositive(int $value, string $parameter = null): void
|
|
{
|
|
global $response;
|
|
if ($value <= 0) {
|
|
$response->error('Parameter ' . $parameter . ' must be a positive integer', 400);
|
|
}
|
|
}
|
|
|
|
public function requireMinLength(string $parameter, int $length): void
|
|
{
|
|
global $response;
|
|
$value = self::getParameter($parameter);
|
|
if (strlen($value) < $length) {
|
|
$response->error('Parameter ' . $parameter . ' must be at least ' . $length . ' characters long', 400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the parameter from the request by name
|
|
* This is a shorthand for the response class method
|
|
* @param string $parameter
|
|
* @return mixed|null
|
|
*/
|
|
public function getParameter(string $parameter): mixed
|
|
{
|
|
global $response;
|
|
return $response->getRequestParameter($parameter);
|
|
}
|
|
|
|
public function requireMaxLength(string $parameter, int $length): void
|
|
{
|
|
global $response;
|
|
$value = self::getParameter($parameter);
|
|
if (strlen($value) > $length) {
|
|
$response->error('Parameter ' . $parameter . ' must be at most ' . $length . ' characters long', 400);
|
|
}
|
|
}
|
|
|
|
public function isParametersSet(array $parameters): bool
|
|
{
|
|
global $response;
|
|
// Check if all required parameters are set
|
|
foreach ( $parameters as $parameter ) {
|
|
if (!$response->isRequestParameterSet($parameter)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* GET route
|
|
* @param string $route Example: /home, /home/{id}
|
|
* @param callable $callback
|
|
* @param array $permissions
|
|
* @return void
|
|
*/
|
|
public function get(string $route, callable $callback, array $permissions = []): void
|
|
{
|
|
$this->registerRoute($route, 'GET', $callback, $permissions);
|
|
}
|
|
|
|
private function registerRoute($route, $method, $callback, $permissions = []): void
|
|
{
|
|
global $router;
|
|
// Wrap the original callback so we can expose the matched route template to fromRoute()
|
|
$self = $this;
|
|
$wrapped = function () use ($callback, $route, $self) {
|
|
// Set the current route template for parameter extraction
|
|
$self->__current_route_template = $route;
|
|
// Execute the original callback
|
|
$callback();
|
|
};
|
|
$router->add($route, $method, $wrapped, self::registerPermissions($permissions, $route, $method));
|
|
}
|
|
|
|
/**
|
|
* Register permissions for the route
|
|
* @param array $permissions The permissions to register
|
|
* @param null $endpoint
|
|
* @param null $method
|
|
* @return array
|
|
*/
|
|
public function registerPermissions(array $permissions, $endpoint = null, $method = null): array
|
|
{
|
|
foreach ( $permissions as $permission => $description ) {
|
|
$this->registerPermission($permission, $description, $endpoint, $method);
|
|
}
|
|
return $this->permissions;
|
|
}
|
|
|
|
/**
|
|
* Register a permission
|
|
* @param string $permission The permission to register (Example: 'modules_motorapi_lookup')
|
|
* @param string $description The description of the permission (Example: 'Lookup license plate information')
|
|
* @param null $endpoint The endpoint of the permission (Example: '/department/license-plate/lookup')
|
|
* @param null $method The method of the permission (Example: 'GET')
|
|
* @return void
|
|
*/
|
|
public function registerPermission(string $permission, string $description = 'No description provided.', $endpoint = null, $method = null): void
|
|
{
|
|
$this->permissions[$endpoint ?? $this->route][$method ?? 'UNKNOWN_METHOD'][$permission] = $description;
|
|
}
|
|
|
|
/**
|
|
* POST route
|
|
* @param string $route Example: /home, /home/{id}
|
|
*/
|
|
public function post(string $route, callable $callback, array $permissions = []): void
|
|
{
|
|
$this->registerRoute($route, 'POST', $callback, $permissions);
|
|
}
|
|
|
|
/**
|
|
* PUT route
|
|
* @param string $route Example: /home, /home/{id}
|
|
*/
|
|
public function put(string $route, callable $callback, array $permissions = []): void
|
|
{
|
|
$this->registerRoute($route, 'PUT', $callback, $permissions);
|
|
}
|
|
|
|
/**
|
|
* DELETE route
|
|
* @param string $route Example: /home, /home/{id}
|
|
*/
|
|
public function delete(string $route, callable $callback, array $permissions = []): void
|
|
{
|
|
$this->registerRoute($route, 'DELETE', $callback, $permissions);
|
|
}
|
|
|
|
/**
|
|
* OPTIONS route
|
|
* @param string $route Example: /home, /home/{id}
|
|
*/
|
|
public function options(string $route, callable $callback, array $permissions = []): void
|
|
{
|
|
$this->registerRoute($route, 'OPTIONS', $callback, $permissions);
|
|
}
|
|
|
|
/**
|
|
* PATCH route
|
|
* @param string $route Example: /home, /home/{id}
|
|
*/
|
|
public function patch(string $route, callable $callback, array $permissions = []): void
|
|
{
|
|
$this->registerRoute($route, 'PATCH', $callback, $permissions);
|
|
}
|
|
|
|
/**
|
|
* Get the value of a route parameter by name from the currently matched route.
|
|
* Example: for template "/users/{id}", current URL "/users/123" → fromRoute('id') === "123".
|
|
* @param string $index The parameter name (without braces), e.g., 'id'
|
|
* @return string|null The extracted value or null if not present
|
|
*/
|
|
public function fromRoute(string $index): ?string
|
|
{
|
|
// Resolve current path without query string
|
|
$currentPath = explode('?', $this->route)[0] ?? '';
|
|
$currentPath = trim($currentPath, '/');
|
|
|
|
// We need the route template used to register this callback
|
|
$template = $this->__current_route_template;
|
|
if ($template === null) {
|
|
// Fallback: no template context — cannot reliably parse; return null
|
|
return null;
|
|
}
|
|
$template = trim($template, '/');
|
|
|
|
$pathSegments = $currentPath === '' ? [] : explode('/', $currentPath);
|
|
$tplSegments = $template === '' ? [] : explode('/', $template);
|
|
|
|
// Quick length guard: router allows alnum-only for params; still, differing counts means no match
|
|
if (count($pathSegments) !== count($tplSegments)) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($tplSegments as $i => $seg) {
|
|
if (preg_match('/^{([a-zA-Z0-9_]+)}$/', $seg, $m)) {
|
|
$name = $m[1];
|
|
if ($name === $index) {
|
|
return $pathSegments[$i] ?? null;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Require plate scanner authentication
|
|
* @return bool
|
|
*/
|
|
|
|
public function requirePlateScannerAuth(): bool
|
|
{
|
|
global $response;
|
|
// Check if the plate scanner has a valid API key
|
|
try {
|
|
$plate_scanner = (new authentication())->get_plate_scanner();
|
|
if (!$plate_scanner) {
|
|
(new logs_o())->add('global', 'global', 1, 0, 'AUTHENTICATION_FAILED', 'Authentication failed. Invalid, or missing API key');
|
|
$response->error('Authentication failed. Invalid or missing API key.', 401);
|
|
}
|
|
} catch (Exception $e) {
|
|
$response->error($e->getMessage(), 400);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Require order access
|
|
* @param int $order_id The order ID
|
|
* @param string|null $permission The permission to check
|
|
* @return bool
|
|
*/
|
|
public function requireOrderAccess(int $order_id, string|null $permission = null): bool
|
|
{
|
|
// TODO: Implement indirect order access, where the user has access to the department, and the department has access to the order
|
|
self::requirePermission('order_access_' . $order_id . ($permission ? '_' . $permission : ''));
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Require reCAPTCHA for the request
|
|
* @return bool
|
|
*/
|
|
public function requireRecaptcha(): bool
|
|
{
|
|
global $response;
|
|
// Check if the reCAPTCHA is valid
|
|
try {
|
|
$recaptcha_response = $response->getRequestParameter('g_recaptcha_response');
|
|
$recaptcha = (new recaptcha())->validate($recaptcha_response);
|
|
if (!$recaptcha) {
|
|
(new logs_o())->add('global', 'global', 1, 0, 'AUTHENTICATION_FAILED', 'Authentication failed. Invalid, or missing reCAPTCHA');
|
|
$response->error('Authentication failed. Invalid or missing reCAPTCHA.', 401);
|
|
}
|
|
} catch (Exception $e) {
|
|
$response->error($e->getMessage(), 400);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get data from the request body or query string by name
|
|
* @param string $name
|
|
* @return string|null
|
|
*/
|
|
public function fromRequest(string $name): ?string
|
|
{
|
|
// If the $_POST variable is set, return the value from the POST variable, otherwise return the value from the query string
|
|
$data = json_decode(file_get_contents('php://input'), true);
|
|
if (is_array($data) && isset($data[$name])) {
|
|
return $data[$name];
|
|
}
|
|
return (isset($_POST[$name])) ? $_POST[$name] : $this->fromQuery($name);
|
|
}
|
|
|
|
/**
|
|
* Get the parameter from the query string by name
|
|
* @param string $name
|
|
* @return string|null
|
|
*/
|
|
public function fromQuery(string $name): ?string
|
|
{
|
|
return (isset($_GET[$name])) ? $_GET[$name] : null;
|
|
}
|
|
|
|
/**
|
|
* Match route
|
|
* @param string $route Example: /home, /home/{id}
|
|
*/
|
|
private function match_route(string $route): bool
|
|
{
|
|
// Check if route is the same, or if it matches the regex pattern
|
|
return $route === $this->route || preg_match($route, $this->route);
|
|
}
|
|
}
|