Files
api/services/nginx/app/routes/systemSearchRoute.php
T
Jeppe B 1e0e051775 Harden Sæby demo registration and department scope (#335)
Complete and secure public customer/driver registration, authoritative limited-backoffice department scope, one-time employee QR login, and pricing concurrency for the Sæby demo.
2026-08-02 11:50:56 +02:00

620 lines
22 KiB
PHP

<?php
namespace routes;
use classes\authentication;
use classes\response;
use classes\router;
use classes\system_search_cache;
use classes\system_search_service;
use Throwable;
use traits\route_t;
class systemSearchRoute
{
use route_t;
public function run(): void
{
$this->get('/search/system', function () {
$this->handleSearchRequest();
});
$this->post('/search/system', function () {
$this->handleSearchRequest();
});
$this->delete('/superuser/search/system/cache', function () {
global /** @var response $response */
$response;
$this->requirePermission('superuser_search_system_cache_clear');
system_search_cache::clearAll();
$response->success([
'message' => 'System search cache cleared',
'query_cache_cleared' => true,
]);
}, [
'superuser_search_system_cache_clear' => 'Clear system-wide search query caches',
]);
$this->post('/superuser/search/system/cache/rebuild', function () {
global /** @var response $response */
$response;
$this->requirePermission('superuser_search_system_cache_rebuild');
$params = $this->getRequestPayload();
$scope = strtolower(trim((string)($params['scope'] ?? 'all')));
$types = $this->parseTypeList($params['types'] ?? []);
$request = system_search_cache::enqueueRebuild($scope, $types);
system_search_cache::clearQueryCaches();
$response->success([
'message' => 'System search cache rebuild queued',
'request' => $request,
'query_cache_cleared' => true,
]);
}, [
'superuser_search_system_cache_rebuild' => 'Queue and trigger a system-wide search cache rebuild',
]);
}
private function handleSearchRequest(): void
{
global /** @var response $response */
/** @var router $router */
$response, $router;
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
if ($user === false && $subuser === false) {
$response->error('Invalid session', 401);
}
$params = $this->getRequestPayload();
$query = trim((string)($params['q'] ?? $params['query'] ?? $params['search'] ?? ''));
if ($query === '') {
$response->error('Missing required parameter: query', 400);
}
$includeTypes = $this->parseTypeList($params['include_types'] ?? []);
$excludeTypes = $this->parseTypeList($params['exclude_types'] ?? []);
$includeAssociations = $this->toBool($params['include_associations'] ?? true, true);
$maxResults = $this->clampMaxResults((int)($params['max_results'] ?? 50));
[$allowedTypes, $ownOnlyTypes] = $this->resolveAllowedTypes();
if (empty($allowedTypes)) {
$response->forbidden($this->searchAccessPermissionCandidates());
}
$permissionsCatalogAll = $this->flattenPermissionCatalog((array)$router->getPermissions());
$permissionsCatalogOwn = [];
if ($user !== false) {
try {
$permissionsCatalogOwn = array_values(array_unique(array_map('strval', (array)$user->getGroup()->getPermissions())));
} catch (Throwable) {
$permissionsCatalogOwn = [];
}
}
$service = new system_search_service();
$result = $service->search([
'query' => $query,
'include_types' => $includeTypes,
'exclude_types' => $excludeTypes,
'allowed_types' => $allowedTypes,
'own_only_types' => $ownOnlyTypes,
'own_customer_number' => $this->resolveEffectiveCustomerNumber(),
'allowed_department_ids' => $this->resolveAllowedDepartmentIds($user),
'permissions_catalog_all' => $permissionsCatalogAll,
'permissions_catalog_own' => $permissionsCatalogOwn,
'module_config_visibility' => $this->buildModuleConfigVisibility(),
'include_associations' => $includeAssociations,
'max_results' => $maxResults,
]);
$response->success($result);
}
private function getRequestPayload(): array
{
$payload = $this->getParametersAsArray();
return is_array($payload) ? $payload : [];
}
private function resolveAllowedTypes(): array
{
$allowed = [];
$ownOnly = [];
foreach ($this->entityPermissionMap() as $type => $permissionSets) {
$hasAll = $this->hasAnyPermission((array)($permissionSets['all'] ?? []));
$hasOwn = $this->hasAnyPermission((array)($permissionSets['own'] ?? []));
if (!$hasAll && !$hasOwn) {
continue;
}
$allowed[] = $type;
if (!$hasAll && $hasOwn) {
$ownOnly[] = $type;
}
}
return [
array_values(array_intersect($this->allEntityTypes(), array_values(array_unique($allowed)))),
array_values(array_intersect($this->allEntityTypes(), array_values(array_unique($ownOnly)))),
];
}
private function entityPermissionMap(): array
{
return [
'objects' => [
'all' => ['list_order_attachments', 'download_order_attachments', 'list_department_selfserve_task_attachments'],
'own' => ['list_own_order_attachments', 'download_order_attachments_own'],
],
'module_config' => [
'all' => $this->moduleConfigPermissions(),
'own' => [],
],
'orders' => [
'all' => ['list_orders', 'fetch_order'],
'own' => ['list_own_orders', 'fetch_own_order'],
],
'order_items' => [
'all' => ['list_order_items'],
'own' => ['list_own_order_items'],
],
'customers' => [
'all' => ['search_customers', 'list_users', 'get_user_from_customer_number'],
'own' => ['user'],
],
'employees' => [
'all' => ['list_users'],
'own' => [],
],
'users' => [
'all' => ['list_users', 'get_user'],
'own' => ['user'],
],
'subusers' => [
'all' => ['list_subuser_grants', 'manage_subuser_grants'],
'own' => ['list_own_subusers', 'list_own_subuser_grants'],
],
'customer_discounts' => [
'all' => ['get_custom_prices_other', 'set_custom_price'],
'own' => [],
],
'customer_fixed_prices' => [
'all' => ['get_customer_fixed_pricing'],
'own' => [],
],
'departments' => [
'all' => ['list_departments'],
'own' => [],
],
'permissions' => [
'all' => ['permissions_list'],
'own' => ['permissions_list_own'],
],
'roles' => [
'all' => ['list_roles'],
'own' => [],
],
'invoices' => [
'all' => ['list_collected_invoices', 'list_collected_invoices_economic_overview'],
'own' => ['user_invoices'],
],
'vehicles' => [
'all' => ['list_vehicles_other', 'list_unknown_customer_vehicles'],
'own' => ['list_own_vehicles'],
],
'bookings' => [
'all' => ['list_bookings'],
'own' => ['list_own_bookings'],
],
'bookings_new' => [
'all' => ['list_bookings', 'statistics_bookings_new'],
'own' => ['list_own_bookings'],
],
'branding' => [
'all' => ['list_branding_options'],
'own' => [],
],
'categories' => [
'all' => ['list_categories'],
'own' => [],
],
'currency_conversion_rates' => [
'all' => ['modules_fxratesapi_rate', 'modules_fxratesapi_rates'],
'own' => [],
],
'customer_codes' => [
'all' => ['get_customer_code', 'add_customer_code'],
'own' => [],
],
'customer_default_department' => [
'all' => ['get_customer_default_department_other', 'add_customer_default_department_other', 'delete_customer_default_department_other'],
'own' => ['get_customer_default_department', 'add_customer_default_department', 'delete_customer_default_department'],
],
'customer_notes' => [
'all' => ['list_customer_notes'],
'own' => [],
],
'customer_vehicles_addons' => [
'all' => ['list_vehicles_addon_other', 'list_vehicle_customer_suggestions'],
'own' => ['list_vehicle_addon_own'],
],
'department_categories' => [
'all' => ['list_department_categories'],
'own' => [],
],
'department_daily_reports' => [
'all' => ['list_department_daily_reports'],
'own' => [],
],
'department_gates' => [
'all' => ['list_department_gates'],
'own' => [],
],
'department_goals' => [
'all' => ['goals_department_list'],
'own' => [],
],
'department_lanes' => [
'all' => ['list_department_lanes'],
'own' => [],
],
'department_notification_sms' => [
'all' => ['department_notification_sms_get'],
'own' => [],
],
'department_relays' => [
'all' => ['list_department_relays'],
'own' => [],
],
'department_selfserve_condition_rules' => [
'all' => ['list_department_selfserve_condition_rules'],
'own' => [],
],
'department_selfserve_conditions' => [
'all' => ['list_department_selfserve_conditions'],
'own' => [],
],
'department_selfserve_questions' => [
'all' => ['list_department_selfserve_questions'],
'own' => [],
],
'department_selfserve_tasks' => [
'all' => ['list_department_selfserve_tasks'],
'own' => [],
],
'department_selfserve_vehicle_conditions' => [
'all' => ['list_department_selfserve_vehicle_conditions'],
'own' => ['list_own_department_selfserve_vehicle_conditions'],
],
'department_time_bookings_entries' => [
'all' => ['department_timebookings_entries_get'],
'own' => [],
],
'department_time_bookings_opening_hours' => [
'all' => ['department_timebookings_opening_hours_get'],
'own' => [],
],
'department_time_bookings_types' => [
'all' => ['department_timebookings_types_get'],
'own' => [],
],
'department_variables' => [
'all' => ['superuser_fetch_department_variables', 'superuser_set_department_variables'],
'own' => [],
],
'fxratesapi_conversion_rates' => [
'all' => ['modules_fxratesapi_rate', 'modules_fxratesapi_rates'],
'own' => [],
],
'module_action_logs' => [
'all' => ['modules_action_logs_view'],
'own' => [],
],
'motorapi_lookups' => [
'all' => ['modules_motorapi_lookup', 'department_license_plate_lookup'],
'own' => [],
],
'notifications' => [
'all' => ['list_all_notifications', 'list_notifications'],
'own' => ['list_own_notifications'],
],
'order_bookings' => [
'all' => ['list_bookings'],
'own' => ['list_own_bookings'],
],
'plate_scanners' => [
'all' => ['list_number_plate_scanners', 'list_department_number_plate_scanners'],
'own' => [],
],
'plate_scans' => [
'all' => ['list_number_plate_scans', 'list_number_plate_scans_department'],
'own' => [],
],
'product_options' => [
'all' => ['list_product_options'],
'own' => [],
],
'products' => [
'all' => ['list_products', 'economic_products_get'],
'own' => [],
],
'stripe_module_customers' => [
'all' => ['modules_stripe_customers_list'],
'own' => [],
],
'stripe_module_orders' => [
'all' => ['list_orders', 'fetch_order'],
'own' => ['list_own_orders', 'fetch_own_order'],
],
'stripe_payment_intents' => [
'all' => ['get_payment_intent', 'confirm_payment_intent'],
'own' => [],
],
'subuser_grants' => [
'all' => ['list_subuser_grants', 'manage_subuser_grants'],
'own' => ['list_own_subuser_grants'],
],
'xlvask_customers' => [
'all' => ['modules_xlvask_customers'],
'own' => [],
],
'xlvask_potential_order_matches' => [
'all' => ['list_potential_order_matches'],
'own' => ['list_own_potential_order_matches'],
],
'xlvask_usage_log_wash_items' => [
'all' => ['modules_xlvask_usageLog', 'list_xlvask_usage_orders_all'],
'own' => ['list_xlvask_usage_orders_own'],
],
'xlvask_usage_logs' => [
'all' => ['modules_xlvask_usageLog', 'list_xlvask_usage_orders_all'],
'own' => ['list_xlvask_usage_orders_own'],
],
'xlvask_vehicle_types' => [
'all' => ['modules_xlvask_internal_vehicle_types'],
'own' => [],
],
'xlvask_vehicles' => [
'all' => ['modules_xlvask_vehicles'],
'own' => [],
],
];
}
private function moduleConfigPermissions(): array
{
return [
'economic_config',
'recaptcha_config',
'email_config',
'backups_config',
'modules_bird_config',
'motorapi_config',
'stripe_config',
'fxratesapi_config',
'weatherapi_config',
'gatewayapi_config',
'xlvask_config',
'entra_config',
'modules_limble_config',
'modules_ocrspace_config',
'modules_openai_config',
'modules_licenseplaterecognizer_config',
'modules_virkdata_config',
'modules_shelly_config',
'modules_selfserve_config',
];
}
private function buildModuleConfigVisibility(): array
{
global $db;
$modulePermissions = [
'economic' => ['economic_config'],
'reCAPTCHA' => ['recaptcha_config'],
'Email' => ['email_config'],
'Backups' => ['backups_config'],
'bird' => ['modules_bird_config'],
'motorapi' => ['motorapi_config'],
'Stripe' => ['stripe_config'],
'fxratesapi' => ['fxratesapi_config'],
'weatherapi' => ['weatherapi_config'],
'GatewayAPI' => ['gatewayapi_config'],
'xlvask' => ['xlvask_config'],
'Entra' => ['entra_config'],
'limble' => ['modules_limble_config'],
'ocrSpace' => ['modules_ocrspace_config'],
'openAI' => ['modules_openai_config'],
'licenseplaterecognizer' => ['modules_licenseplaterecognizer_config'],
'virkdata' => ['modules_virkdata_config'],
'shelly' => ['modules_shelly_config'],
'selfserve' => ['modules_selfserve_config'],
];
$visibility = [];
try {
$result = $db->query('SELECT DISTINCT module FROM module_config');
if ($result instanceof \mysqli_result) {
$rows = $db->fetch_all($result);
foreach ($rows as $row) {
$module = (string)($row['module'] ?? '');
if ($module === '') {
continue;
}
$candidates = $modulePermissions[$module] ?? [];
if (empty($candidates)) {
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '', $module) ?? '');
if ($slug !== '') {
$candidates[] = $slug . '_config';
$candidates[] = 'modules_' . $slug . '_config';
}
}
$visibility[$module] = $this->hasAnyPermission($candidates);
}
}
} catch (Throwable) {
// Fail open for compatibility if module map cannot be loaded.
}
return $visibility;
}
private function hasAnyPermission(array $permissions): bool
{
foreach ($permissions as $permission) {
if (!is_string($permission) || $permission === '') {
continue;
}
if ($this->hasPermission($permission)) {
return true;
}
}
return false;
}
private function flattenPermissionCatalog(array $permissions): array
{
$flat = [];
$walker = function (mixed $node) use (&$flat, &$walker): void {
if (!is_array($node)) {
return;
}
foreach ($node as $key => $value) {
if (is_string($key) && is_string($value)) {
$flat[$key] = $value;
continue;
}
if (is_array($value)) {
$walker($value);
}
}
};
$walker($permissions);
return $flat;
}
/**
* @param mixed $user
* @return array<int, int>
*/
private function resolveAllowedDepartmentIds(mixed $user): array
{
if ($user === false) {
return [];
}
try {
return $this->effectiveDepartmentIds($user);
} catch (Throwable) {
return [];
}
}
private function parseTypeList(mixed $value): array
{
$result = [];
$raw = [];
if (is_array($value)) {
$raw = $value;
} elseif (is_string($value)) {
$trimmed = trim($value);
if ($trimmed === '') {
return [];
}
if (str_starts_with($trimmed, '[')) {
$decoded = json_decode($trimmed, true);
if (is_array($decoded)) {
$raw = $decoded;
} else {
$raw = explode(',', $trimmed);
}
} else {
$raw = explode(',', $trimmed);
}
} elseif ($value !== null) {
$raw = [$value];
}
foreach ($raw as $item) {
if (!is_string($item)) {
continue;
}
$normalized = strtolower(trim($item));
if ($normalized === '') {
continue;
}
$result[] = $normalized;
}
return array_values(array_unique($result));
}
private function toBool(mixed $value, bool $default): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return (bool)$value;
}
if (is_string($value)) {
$parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
return $parsed ?? $default;
}
return $default;
}
private function clampInt(int $value, int $min, int $max, int $default): int
{
if ($value === 0) {
$value = $default;
}
if ($value < $min) {
return $min;
}
if ($value > $max) {
return $max;
}
return $value;
}
private function clampMaxResults(int $value): int
{
return $this->clampInt($value, 1, 50, 50);
}
private function allEntityTypes(): array
{
return array_keys($this->entityPermissionMap());
}
/**
* Return all permission keys that can unlock at least one searchable entity type.
*
* @return array<int, string>
*/
private function searchAccessPermissionCandidates(): array
{
$permissions = [];
foreach ($this->entityPermissionMap() as $permissionSets) {
foreach ((array)($permissionSets['all'] ?? []) as $permission) {
if (is_string($permission) && $permission !== '') {
$permissions[] = $permission;
}
}
foreach ((array)($permissionSets['own'] ?? []) as $permission) {
if (is_string($permission) && $permission !== '') {
$permissions[] = $permission;
}
}
}
return array_values(array_unique($permissions));
}
}