get('/search/system', function () { ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/search/system'); $this->handleSearchRequest(); }); $this->post('/search/system', function () { ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/search/system'); $this->handleSearchRequest(); }); $this->delete('/superuser/search/system/cache', function () { ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/search/system/cache'); 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 () { ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/search/system/cache/rebuild'); 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 */ 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 */ 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)); } }