loadProfileLocation(); } public function render() { return view('livewire.main-search-bar'); } public function selectSuggestion($suggestionIndex) { \Log::info('selectSuggestion called', ['index' => $suggestionIndex, 'suggestions' => $this->suggestions]); // Get the suggestion text from the suggestions collection $suggestion = $this->suggestions[$suggestionIndex] ?? null; \Log::info('Found suggestion', ['suggestion' => $suggestion]); if ($suggestion && isset($suggestion['text'])) { // Simply set the search to the suggestion text $this->search = $suggestion['text']; \Log::info('Set search to', ['search' => $this->search]); // Clear suggestions $this->suggestions = collect(); // Execute search and redirect $this->updatedSearch(); return $this->showSearchResults(); } \Log::warning('selectSuggestion failed - no valid suggestion found'); } private function loadProfileLocation() { try { if (function_exists('getActiveProfile')) { $activeProfile = getActiveProfile(); if ($activeProfile && $activeProfile->locations) { $this->profileLocation = $activeProfile->locations->first(); if ($this->profileLocation) { $this->locationHierarchy = $this->profileLocation->getCompleteHierarchy(); } } } } catch (\Exception $e) { \Log::warning('MainSearchBar: Failed to load profile location: ' . $e->getMessage()); $this->profileLocation = null; $this->locationHierarchy = []; } } private function getLocationName($location) { if (!$location) { return null; } // If location is an array or collection, return null if (is_array($location) || ($location instanceof \Illuminate\Support\Collection)) { return null; } $locale = app()->getLocale(); // Try to get translated name try { // Check if location has a 'name' property that is an array of translations if (isset($location->name) && is_array($location->name)) { // Find translation matching current locale foreach ($location->name as $translation) { if (isset($translation['locale']) && $translation['locale'] === $locale && isset($translation['name'])) { return $translation['name']; } } // Fall back to English if current locale not found foreach ($location->name as $translation) { if (isset($translation['locale']) && $translation['locale'] === 'en' && isset($translation['name'])) { return $translation['name']; } } // Fall back to first translation if (isset($location->name[0]['name'])) { return $location->name[0]['name']; } } // Try to get from translations relationship if (method_exists($location, 'getRelation') && $location->relationLoaded('translations')) { $translations = $location->getRelation('translations'); if ($translations && $translations->count() > 0) { $translation = $translations->where('locale', $locale)->first(); if ($translation && isset($translation->name)) { return $translation->name; } } } } catch (\Exception $e) { \Log::warning('MainSearchBar: Failed to get translated location name', [ 'error' => $e->getMessage() ]); } // Fall back to default name property if it's a string if (isset($location->name) && is_string($location->name)) { return $location->name; } return null; } private function getProfileSearchFields(): array { $locale = app()->getLocale(); // Text/tag fields — use per-field language analyzers (no override) return [ 'cyclos_skills^' . timebank_config('main_search_bar.boosted_fields.profile.cyclos_skills', '1'), 'tags.contexts.tags.name_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.profile.name', '1'), 'tags.contexts.categories.name_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.profile.tag_categories', '1'), 'motivation_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.profile.motivation', '1'), 'about_short_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.profile.about_short', '1'), 'about_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.profile.about', '1'), ]; } // Name fields use an edge n-gram analyzer at index time; standard analyzer must be used at query time. private function getProfileNameFields(): array { return [ 'name^' . timebank_config('main_search_bar.boosted_fields.profile.name', '1'), 'full_name^' . timebank_config('main_search_bar.boosted_fields.profile.full_name', '1'), ]; } // Location fields are searchable (e.g. "computer brussels" matches city) using standard analyzer. // Kept separate from proximity boosts in addLocationBoosts() which boost by the searcher's own location. private function getProfileLocationFields(): array { return [ 'locations.district^' . timebank_config('main_search_bar.boosted_fields.profile.district', '1'), 'locations.city^' . timebank_config('main_search_bar.boosted_fields.profile.city', '1'), 'locations.division^' . timebank_config('main_search_bar.boosted_fields.profile.division', '1'), 'locations.country^' . timebank_config('main_search_bar.boosted_fields.profile.country', '1'), ]; } // Cross-fields query covers name + location fields only. // This rewards profiles where different query terms match across name/location // (e.g. "computer" in tags AND "brussels" in city). // Free-text fields (about, cyclos_skills) are intentionally excluded to avoid // inflating scores for incidental word mentions in profile text. private function getProfileCrossFields(): array { return [ 'name', 'full_name', 'locations.city', 'locations.district', 'locations.division', 'locations.country', ]; } private function getPostSearchFields(): array { $locale = app()->getLocale(); return [ "post_translations.title_{$locale}^" . timebank_config('main_search_bar.boosted_fields.post.title', '3'), "post_translations.content_{$locale}^" . timebank_config('main_search_bar.boosted_fields.post.content', '1'), "post_translations.excerpt_{$locale}^" . timebank_config('main_search_bar.boosted_fields.post.excerpt', '2'), // Add excerpt "post_category.names.name_{$locale}^" . timebank_config('main_search_bar.boosted_fields.post.post_category_name', '2'), // 'locations.district^' . timebank_config('main_search_bar.boosted_fields.profile.district', '1'), // 'locations.city^' . timebank_config('main_search_bar.boosted_fields.profile.city', '1'), // 'locations.division^' . timebank_config('main_search_bar.boosted_fields.profile.division', '1'), // 'locations.country^' . timebank_config('main_search_bar.boosted_fields.profile.country', '1'), ]; } private function getProfileHighlightFields(): array { $locale = app()->getLocale(); // Apply global highlight settings to each field $highlightOptions = [ 'fragment_size' => timebank_config('main_search_bar.search.fragment_size', 80), 'number_of_fragments' => timebank_config('main_search_bar.search.number_of_fragments', 1), 'fragmenter' => timebank_config('main_search_bar.search.fragmenter', 'span'), 'order' => timebank_config('main_search_bar.search.order', 'score'), ]; return [ // Content fields only - no location fields for highlighting 'name' => $highlightOptions, 'full_name' => $highlightOptions, 'about_short_' . $locale => $highlightOptions, 'about_' . $locale => $highlightOptions, 'cyclos_skills' => $highlightOptions, 'tags.contexts.tags.name_' . $locale => $highlightOptions, 'tags.contexts.categories.name_' . $locale => $highlightOptions, // Location fields removed from highlighting (still used for search boosting and ordering) ]; } private function getPostHighlightFields(): array { $locale = app()->getLocale(); // Apply global highlight settings to each field $highlightOptions = [ 'fragment_size' => timebank_config('main_search_bar.search.fragment_size', 80), 'number_of_fragments' => timebank_config('main_search_bar.search.number_of_fragments', 1), 'fragmenter' => timebank_config('main_search_bar.search.fragmenter', 'span'), 'order' => timebank_config('main_search_bar.search.order', 'score'), ]; return [ // Content fields only - no location fields for highlighting "post_translations.title_{$locale}" => $highlightOptions, "post_translations.content_{$locale}" => $highlightOptions, "post_translations.excerpt_{$locale}" => $highlightOptions, "post_category.names.name_{$locale}" => $highlightOptions, // Location fields removed from highlighting (still used for search boosting and ordering) ]; } private function getCallHighlightFields(): array { $locale = app()->getLocale(); $highlightOptions = [ 'fragment_size' => timebank_config('main_search_bar.search.fragment_size', 80), 'number_of_fragments' => timebank_config('main_search_bar.search.number_of_fragments', 1), 'fragmenter' => timebank_config('main_search_bar.search.fragmenter', 'span'), 'order' => timebank_config('main_search_bar.search.order', 'score'), ]; $fields = [ "tag.name_{$locale}" => $highlightOptions, "call_translations.content_{$locale}" => $highlightOptions, ]; foreach (['en', 'nl', 'fr', 'de', 'es'] as $otherLocale) { if ($otherLocale !== $locale) { $fields["tag.name_{$otherLocale}"] = $highlightOptions; $fields["call_translations.content_{$otherLocale}"] = $highlightOptions; } } return $fields; } public function updatedSearch() { // Store the raw search term before any processing $this->rawSearchTerm = $this->search; // Process the search term $search = $this->search; // Allow Unicode letters (including German umlauts, Spanish accents, etc.), numbers, and spaces $search = preg_replace('/[^\p{L}\p{N}\s]/u', '', $search); $search = rtrim($search); if (strlen($search) < 2) { $this->suggestions = collect(); $this->fetchedResults = []; $this->suggestionMap = []; return; } $cleanSearch = trim(str_replace('*', '', $search)); try { // Use MixedSearch with proper highlighting $currentTime = now()->toISOString(); $locale = app()->getLocale(); // Create the main bool query $mainBoolQuery = new BoolQuery(); // Profile queries use three separate sub-queries combined with minimum_should_match:1: // 1. Text/tag fields — language-specific analyzers (education->educ stem etc.) // 2. Name fields — standard analyzer (edge n-gram index, bypass n-gram at query time) // 3. Location fields — standard analyzer (so "brussels" matches the city field) // A separate exact-name phrase SHOULD clause (^10) boosts profiles whose name matches exactly. // addLocationBoosts() adds proximity bonuses based on the searcher's own location. $exactNameFields = ['name^10', 'full_name^10']; $searchType = timebank_config('main_search_bar.search.type', 'best_fields'); $userMatchBool = new BoolQuery(); $userMatchBool->addParameter('minimum_should_match', 1); $userTextQuery = new MultiMatchQuery($this->getProfileSearchFields(), $cleanSearch); $userTextQuery->addParameter('type', $searchType); $userTextQuery->addParameter('boost', timebank_config('main_search_bar.boosted_models.user', 1)); $userMatchBool->add($userTextQuery, BoolQuery::SHOULD); $userNameQuery = new MultiMatchQuery($this->getProfileNameFields(), $cleanSearch); $userNameQuery->addParameter('type', 'best_fields'); $userNameQuery->addParameter('analyzer', 'standard'); $userMatchBool->add($userNameQuery, BoolQuery::SHOULD); $userLocationQuery = new MultiMatchQuery($this->getProfileLocationFields(), $cleanSearch); $userLocationQuery->addParameter('type', 'best_fields'); $userLocationQuery->addParameter('analyzer', 'standard'); $userLocationQuery->addParameter('boost', 3.0); $userMatchBool->add($userLocationQuery, BoolQuery::SHOULD); $userCrossQuery = new MultiMatchQuery($this->getProfileCrossFields(), $cleanSearch); $userCrossQuery->addParameter('type', 'cross_fields'); $userCrossQuery->addParameter('analyzer', 'standard'); $userCrossQuery->addParameter('boost', 5.0); $userMatchBool->add($userCrossQuery, BoolQuery::SHOULD); $userBoolQuery = new BoolQuery(); $userBoolQuery->add(new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('__class_name', 'App\Models\User'), BoolQuery::MUST); $userBoolQuery->add($userMatchBool, BoolQuery::MUST); $userExactNameQuery = new MultiMatchQuery($exactNameFields, $cleanSearch); $userExactNameQuery->addParameter('type', 'phrase'); $userExactNameQuery->addParameter('analyzer', 'standard'); $userBoolQuery->add($userExactNameQuery, BoolQuery::SHOULD); $this->addLocationBoosts($userBoolQuery); $orgMatchBool = new BoolQuery(); $orgMatchBool->addParameter('minimum_should_match', 1); $orgTextQuery = new MultiMatchQuery($this->getProfileSearchFields(), $cleanSearch); $orgTextQuery->addParameter('type', $searchType); $orgTextQuery->addParameter('boost', timebank_config('main_search_bar.boosted_models.organization', 3)); $orgMatchBool->add($orgTextQuery, BoolQuery::SHOULD); $orgNameQuery = new MultiMatchQuery($this->getProfileNameFields(), $cleanSearch); $orgNameQuery->addParameter('type', 'best_fields'); $orgNameQuery->addParameter('analyzer', 'standard'); $orgMatchBool->add($orgNameQuery, BoolQuery::SHOULD); $orgLocationQuery = new MultiMatchQuery($this->getProfileLocationFields(), $cleanSearch); $orgLocationQuery->addParameter('type', 'best_fields'); $orgLocationQuery->addParameter('analyzer', 'standard'); $orgLocationQuery->addParameter('boost', 3.0); $orgMatchBool->add($orgLocationQuery, BoolQuery::SHOULD); $orgCrossQuery = new MultiMatchQuery($this->getProfileCrossFields(), $cleanSearch); $orgCrossQuery->addParameter('type', 'cross_fields'); $orgCrossQuery->addParameter('analyzer', 'standard'); $orgCrossQuery->addParameter('boost', 5.0); $orgMatchBool->add($orgCrossQuery, BoolQuery::SHOULD); $orgBoolQuery = new BoolQuery(); $orgBoolQuery->add(new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('__class_name', 'App\Models\Organization'), BoolQuery::MUST); $orgBoolQuery->add($orgMatchBool, BoolQuery::MUST); $orgExactNameQuery = new MultiMatchQuery($exactNameFields, $cleanSearch); $orgExactNameQuery->addParameter('type', 'phrase'); $orgExactNameQuery->addParameter('analyzer', 'standard'); $orgBoolQuery->add($orgExactNameQuery, BoolQuery::SHOULD); $this->addLocationBoosts($orgBoolQuery); $bankMatchBool = new BoolQuery(); $bankMatchBool->addParameter('minimum_should_match', 1); $bankTextQuery = new MultiMatchQuery($this->getProfileSearchFields(), $cleanSearch); $bankTextQuery->addParameter('type', $searchType); $bankTextQuery->addParameter('boost', timebank_config('main_search_bar.boosted_models.bank', 3)); $bankMatchBool->add($bankTextQuery, BoolQuery::SHOULD); $bankNameQuery = new MultiMatchQuery($this->getProfileNameFields(), $cleanSearch); $bankNameQuery->addParameter('type', 'best_fields'); $bankNameQuery->addParameter('analyzer', 'standard'); $bankMatchBool->add($bankNameQuery, BoolQuery::SHOULD); $bankLocationQuery = new MultiMatchQuery($this->getProfileLocationFields(), $cleanSearch); $bankLocationQuery->addParameter('type', 'best_fields'); $bankLocationQuery->addParameter('analyzer', 'standard'); $bankLocationQuery->addParameter('boost', 3.0); $bankMatchBool->add($bankLocationQuery, BoolQuery::SHOULD); $bankCrossQuery = new MultiMatchQuery($this->getProfileCrossFields(), $cleanSearch); $bankCrossQuery->addParameter('type', 'cross_fields'); $bankCrossQuery->addParameter('analyzer', 'standard'); $bankCrossQuery->addParameter('boost', 5.0); $bankMatchBool->add($bankCrossQuery, BoolQuery::SHOULD); $bankBoolQuery = new BoolQuery(); $bankBoolQuery->add(new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('__class_name', 'App\Models\Bank'), BoolQuery::MUST); $bankBoolQuery->add($bankMatchBool, BoolQuery::MUST); $bankExactNameQuery = new MultiMatchQuery($exactNameFields, $cleanSearch); $bankExactNameQuery->addParameter('type', 'phrase'); $bankExactNameQuery->addParameter('analyzer', 'standard'); $bankBoolQuery->add($bankExactNameQuery, BoolQuery::SHOULD); $this->addLocationBoosts($bankBoolQuery); // Add profile queries to main query with their individual boosts $mainBoolQuery->add($userBoolQuery, BoolQuery::SHOULD); $mainBoolQuery->add($orgBoolQuery, BoolQuery::SHOULD); $mainBoolQuery->add($bankBoolQuery, BoolQuery::SHOULD); // Add posts search query $postsBoolQuery = $this->addPostsSearch($cleanSearch, $currentTime, $locale); $mainBoolQuery->add($postsBoolQuery, BoolQuery::SHOULD); // Add calls search query $callsBoolQuery = $this->addCallsSearch($cleanSearch, $currentTime, $locale); $mainBoolQuery->add($callsBoolQuery, BoolQuery::SHOULD); // Create highlight configuration $highlight = new Highlight(); $highlight->addParameter('fragment_size', timebank_config('main_search_bar.search.fragment_size', 80)); $highlight->addParameter('number_of_fragments', timebank_config('main_search_bar.search.number_of_fragments', 1)); $highlight->addParameter('fragmenter', timebank_config('main_search_bar.search.fragmenter', 'span')); $highlight->addParameter('order', timebank_config('main_search_bar.search.order', 'score')); // Limit total fragments across all fields - use max_analyzed_offset to limit highlighting $highlight->addParameter('max_analyzed_offset', timebank_config('main_search_bar.search.fragment_size', 80)); $highlight->addParameter('require_field_match', true); // Add pre/post tags for highlighting $preTags = timebank_config('main_search_bar.search.pre-tags'); $postTags = timebank_config('main_search_bar.search.post-tags'); if ($preTags && $postTags) { $highlight->addParameter('pre_tags', is_array($preTags) ? $preTags : [$preTags]); $highlight->addParameter('post_tags', is_array($postTags) ? $postTags : [$postTags]); } // Add profile highlight fields foreach ($this->getProfileHighlightFields() as $field => $options) { $highlight->addField($field, $options); } // Add post highlight fields foreach ($this->getPostHighlightFields() as $field => $options) { $highlight->addField($field, $options); } // Add call highlight fields foreach ($this->getCallHighlightFields() as $field => $options) { $highlight->addField($field, $options); } // Execute MixedSearch with callback // Pass empty string so SearchFactory does not inject a query_string MUST clause — // all matching is handled by our own $mainBoolQuery in the callback. $rawResponse = MixedSearch::search('', function (Client $client, Search $body) use ($mainBoolQuery, $highlight, $cleanSearch, $locale) { // Add the query $body->addQuery($mainBoolQuery); // Add highlighting $body->addHighlight($highlight); // Set size $body->setSize(timebank_config('main_search_bar.search.max_results', 50)); // Log the query for debugging $queryArray = $body->toArray(); \Log::info('MainSearchBar: Elasticsearch query', [ 'locale' => $locale, 'search_term' => $cleanSearch, 'query' => json_encode($queryArray) ]); // Execute the search return $client->search([ 'index' => implode(',', timebank_config('main_search_bar.model_indices', [])), 'body' => $queryArray, ])->asArray(); })->raw(); // Get models from the response $results = collect(); if (isset($rawResponse['hits']['hits'])) { foreach ($rawResponse['hits']['hits'] as $hit) { $modelClass = $hit['_source']['__class_name'] ?? null; $modelId = $hit['_source']['id'] ?? null; if ($modelClass && $modelId && class_exists($modelClass)) { try { $model = $modelClass::find($modelId); if ($model) { $results->push($model); } } catch (\Exception $e) { \Log::warning("MainSearchBar: Could not load model {$modelClass}:{$modelId}", ['error' => $e->getMessage()]); } } } } $this->processMixedResults($results, $rawResponse, $cleanSearch); } catch (\Exception $e) { \Log::error('MainSearchBar: Enhanced search failed', [ 'error' => $e->getMessage(), 'search_term' => $cleanSearch ]); $this->suggestions = collect(); $this->fetchedResults = []; $this->total = 0; $this->suggestionMap = []; } } private function addPostsSearch(string $cleanSearch, string $currentTime, string $locale): BoolQuery { $postsBoolQuery = new BoolQuery(); $postsBoolQuery->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('__class_name', 'App\Models\Post'), BoolQuery::MUST ); $postSearchFields = [ 'post_translations.title_' . $locale . '^' .timebank_config('main_search_bar.boosted_fields.post.title', '1'), 'post_translations.content_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.post.content', '1'), 'post_translations.excerpt_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.post.excerpt', '1'), 'post_translations.category.names.name_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.post.category_name', '1'), ]; $postMultiMatchQuery = new MultiMatchQuery($postSearchFields, $cleanSearch); $postMultiMatchQuery->addParameter('boost', timebank_config('main_search_bar.boosted_models.post', 4)); $postsBoolQuery->add($postMultiMatchQuery, BoolQuery::MUST); $this->addPostPublicationFilters($postsBoolQuery, $currentTime, $locale); $includedCategoryIds = timebank_config('main_search_bar.category_ids_posts'); if (!empty($includedCategoryIds)) { $categoryBoolQuery = new BoolQuery(); foreach ($includedCategoryIds as $categoryId) { $categoryBoolQuery->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('category_id', $categoryId), BoolQuery::SHOULD ); } $postsBoolQuery->add($categoryBoolQuery, BoolQuery::MUST); } return $postsBoolQuery; } private function addPostPublicationFilters(BoolQuery $boolQuery, string $currentTime, string $locale): void { // From date: must exist AND be in the past (null means NOT published) $boolQuery->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery( "post_translations.from_{$locale}" ), BoolQuery::MUST ); $boolQuery->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery( "post_translations.from_{$locale}", ['lte' => $currentTime] ), BoolQuery::MUST ); // Till date: (NOT exists) OR (exists AND in future) = never expires OR not yet expired $tillFilter = new BoolQuery(); // Option 1: field doesn't exist (null) $tillNotExists = new BoolQuery(); $tillNotExists->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery( "post_translations.till_{$locale}" ), BoolQuery::MUST_NOT ); $tillFilter->add($tillNotExists, BoolQuery::SHOULD); // Option 2: field exists and is in the future $tillInFuture = new BoolQuery(); $tillInFuture->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery( "post_translations.till_{$locale}" ), BoolQuery::MUST ); $tillInFuture->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery( "post_translations.till_{$locale}", ['gte' => $currentTime] ), BoolQuery::MUST ); $tillFilter->add($tillInFuture, BoolQuery::SHOULD); $boolQuery->add($tillFilter, BoolQuery::MUST); // Deleted date: (NOT exists) OR (exists AND in future) = not deleted OR scheduled for future $deletionFilter = new BoolQuery(); // Option 1: field doesn't exist (null) $deletionNotExists = new BoolQuery(); $deletionNotExists->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery( "post_translations.deleted_at_{$locale}" ), BoolQuery::MUST_NOT ); $deletionFilter->add($deletionNotExists, BoolQuery::SHOULD); // Option 2: field exists and is in the future $deletionInFuture = new BoolQuery(); $deletionInFuture->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery( "post_translations.deleted_at_{$locale}" ), BoolQuery::MUST ); $deletionInFuture->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery( "post_translations.deleted_at_{$locale}", ['gt' => $currentTime] ), BoolQuery::MUST ); $deletionFilter->add($deletionInFuture, BoolQuery::SHOULD); $boolQuery->add($deletionFilter, BoolQuery::MUST); } private function addCallsSearch(string $cleanSearch, string $currentTime, string $locale): BoolQuery { $callsBoolQuery = new BoolQuery(); $callsBoolQuery->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('__class_name', 'App\Models\Call'), BoolQuery::MUST ); // Search current locale with higher boost, plus all other locales as fallback // This handles compound words in NL/DE where a search term may be part of a compound $callSearchFields = [ 'tag.name_' . $locale . '^3', 'call_translations.content_' . $locale . '^1', 'callable.name^1', ]; foreach (['en', 'nl', 'fr', 'de', 'es'] as $otherLocale) { if ($otherLocale !== $locale) { $callSearchFields[] = 'tag.name_' . $otherLocale . '^1'; $callSearchFields[] = 'call_translations.content_' . $otherLocale . '^0.5'; } } // Standard multi-match across all locale fields $callMultiMatchQuery = new \ONGR\ElasticsearchDSL\Query\FullText\MultiMatchQuery($callSearchFields, $cleanSearch); $callMultiMatchQuery->addParameter('boost', timebank_config('main_search_bar.boosted_models.post', 4)); $callsBoolQuery->add($callMultiMatchQuery, BoolQuery::SHOULD); // Wildcard fallback for compound words (e.g. NL/DE where search term is part of a compound) // Added as SHOULD alongside multi-match; the outer MUST requirement is satisfied if either matches foreach (['en', 'nl', 'fr', 'de', 'es'] as $wLocale) { $callsBoolQuery->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\WildcardQuery('tag.name_' . $wLocale, strtolower($cleanSearch) . '*'), BoolQuery::SHOULD ); } // Require at least one text clause to match (minimum_should_match on the should clauses) $callsBoolQuery->addParameter('minimum_should_match', 1); // from: must exist and be in the past $callsBoolQuery->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery('from'), BoolQuery::MUST ); $callsBoolQuery->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery('from', ['lte' => $currentTime]), BoolQuery::MUST ); // till: not exists (no expiry) OR exists and in the future $tillFilter = new BoolQuery(); $tillNotExists = new BoolQuery(); $tillNotExists->add(new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery('till'), BoolQuery::MUST_NOT); $tillFilter->add($tillNotExists, BoolQuery::SHOULD); $tillInFuture = new BoolQuery(); $tillInFuture->add(new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery('till'), BoolQuery::MUST); $tillInFuture->add(new \ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery('till', ['gte' => $currentTime]), BoolQuery::MUST); $tillFilter->add($tillInFuture, BoolQuery::SHOULD); $callsBoolQuery->add($tillFilter, BoolQuery::MUST); return $callsBoolQuery; } private function addLocationBoosts(BoolQuery $boolQuery) { if (empty($this->locationHierarchy)) { return; } if (isset($this->locationHierarchy['district']) && $this->locationHierarchy['district']) { $districtName = $this->getLocationName($this->locationHierarchy['district']); if ($districtName) { $districtQuery = new \ONGR\ElasticsearchDSL\Query\FullText\MatchQuery('locations.district', $districtName); $districtQuery->addParameter('boost', timebank_config('search_optimization.location_boosts.same_district', 5.0)); $boolQuery->add($districtQuery, BoolQuery::SHOULD); } } if (isset($this->locationHierarchy['city']) && $this->locationHierarchy['city']) { $cityName = $this->getLocationName($this->locationHierarchy['city']); if ($cityName) { $cityQuery = new \ONGR\ElasticsearchDSL\Query\FullText\MatchQuery('locations.city', $cityName); $cityQuery->addParameter('boost', timebank_config('search_optimization.location_boosts.same_city', 3.0)); $boolQuery->add($cityQuery, BoolQuery::SHOULD); } } if (isset($this->locationHierarchy['division']) && $this->locationHierarchy['division']) { $divisionName = $this->getLocationName($this->locationHierarchy['division']); \Log::info('MainSearchBar: Division location boost', [ 'division_object' => json_encode($this->locationHierarchy['division']), 'division_name' => $divisionName, 'locale' => app()->getLocale() ]); if ($divisionName) { $divisionQuery = new \ONGR\ElasticsearchDSL\Query\FullText\MatchQuery('locations.division', $divisionName); $divisionQuery->addParameter('boost', timebank_config('search_optimization.location_boosts.same_division', 2.0)); $boolQuery->add($divisionQuery, BoolQuery::SHOULD); } } if (isset($this->locationHierarchy['country']) && $this->locationHierarchy['country']) { $countryName = $this->getLocationName($this->locationHierarchy['country']); if ($countryName) { $countryQuery = new \ONGR\ElasticsearchDSL\Query\FullText\MatchQuery('locations.country', $countryName); $countryQuery->addParameter('boost', timebank_config('search_optimization.location_boosts.same_country', 1.5)); $boolQuery->add($countryQuery, BoolQuery::SHOULD); } } } private function processMixedResults($models, array $rawResponse, string $cleanSearch): void { $cardData = []; foreach ($models as $model) { $type = get_class($model); if ($type === \App\Models\Post::class) { $result = $this->processPostCard($model); } elseif ($type === \App\Models\Call::class) { // Always check current DB state regardless of index freshness if ($model->trashed()) continue; if ($model->is_suppressed) continue; if ($model->is_paused) continue; if ($model->till !== null && $model->till->isPast()) continue; if (!$model->is_public && !\Illuminate\Support\Facades\Auth::check()) continue; $result = $this->processCallCard($model); } else { $result = $this->processProfileCard($model); } if (!empty($result)) { $cardData[] = $result; } } $results = []; $rawHits = $rawResponse['hits']['hits'] ?? []; // Key by model+id to avoid collisions between different model types with the same id $cardDataByKey = collect($cardData)->keyBy(fn($r) => $r['model'] . ':' . $r['id']); foreach ($rawHits as $rawItem) { $id = $rawItem['_source']['id'] ?? null; $modelClass = $rawItem['_source']['__class_name'] ?? null; $key = $modelClass . ':' . $id; if ($id && isset($cardDataByKey[$key])) { $result = $cardDataByKey[$key]; $result['score'] = round(($rawItem['_score'] ?? 1.0) / 4, 1); // Limit highlights to only the most important one per result $highlight = $rawItem['highlight'] ?? []; $limitedHighlight = $this->limitHighlights($highlight); // CRITICAL XSS PROTECTION POINT // Sanitize highlights to prevent XSS attacks from user-generated content // Elasticsearch returns highlights containing user profile data that could include malicious HTML/JS // This MUST be sanitized before caching or displaying to prevent stored XSS attacks // DO NOT remove or bypass this sanitization - it protects all search result displays $result['highlight'] = $this->sanitizeHighlights($limitedHighlight); $results[] = $result; } } $extractedData = $this->processAndScoreResults($results, $cleanSearch); if (timebank_config('main_search_bar.suggestions') > 0) { // Create suggestions with mapped search terms $this->suggestionMap = []; $suggestionIndex = 0; $suggestions = collect($extractedData)->flatMap(function ($item) use (&$suggestionIndex, $cleanSearch) { $itemSuggestions = $item['suggest'] ?? []; $mappedSuggestions = []; foreach ($itemSuggestions as $suggestion) { $this->suggestionMap[$suggestionIndex] = [ 'display_text' => $suggestion, 'original_term' => $cleanSearch, // Always use the original search term 'field_source' => $item['highlight_field'] ?? 'unknown' ]; $mappedSuggestions[] = [ 'text' => $suggestion, 'index' => $suggestionIndex ]; $suggestionIndex++; } return $mappedSuggestions; })->unique('text')->take(timebank_config('main_search_bar.suggestions')); $this->suggestions = $suggestions; } $this->fetchedResults = $extractedData->values()->all(); $this->total = $rawResponse['hits']['total']['value'] ?? $models->count(); } private function processPostCard($model): array { // Load relationships only when needed if (!$model->relationLoaded('category')) { $model = \App\Models\Post::with(['category.categoryable'])->find($model->id); } if (!$model) { return []; } $locale = app()->getLocale(); $translation = $model->translations()->where('locale', $locale)->first(); // Handle missing translation if (!$translation) { return []; // Skip this post } // Check publication status using dates (same logic as in search) $currentTime = now(); $isPublished = true; $filterReason = ''; // From date MUST exist and be in the past (null = NOT published) if (!$translation->from) { $isPublished = false; // No publication date = not published $filterReason = 'No from date (unpublished)'; } elseif ($currentTime->lt($translation->from)) { $isPublished = false; // Not yet published $filterReason = 'Future from date: ' . $translation->from; } // Till date can be null (never expires) or must be in the future if ($translation->till && $currentTime->gt($translation->till)) { $isPublished = false; // Publication ended $filterReason = 'Past till date: ' . $translation->till; } // Deleted date can be null (not deleted) or must be in the future if ($translation->deleted_at && $currentTime->gte($translation->deleted_at)) { $isPublished = false; // Scheduled deletion occurred $filterReason = 'Deleted at: ' . $translation->deleted_at; } if (!$isPublished) { return []; // Skip unpublished posts } $categoryId = optional($model->category)->id; $photoUrl = $model->getFirstMediaUrl('posts', 'hero'); $categoryName = null; if ($model->category && $model->category->relationLoaded('translations')) { $categoryName = optional($model->category->translations->where('locale', $locale)->first())->name; } return [ 'id' => $model->id, 'model' => get_class($model), 'model_object' => $model, 'category_id' => $categoryId, 'category' => $categoryName, 'title' => $translation->title ?? '', 'subtitle' => $translation->excerpt ?? '', 'photo' => $photoUrl, 'location_short' => '', 'location' => '', 'status' => '', 'location_proximity' => $this->calculateLocationProximity($model), ]; } private function processCallCard($model): array { $model->loadMissing([ 'callable', 'tag.contexts.category.translations', 'tag.contexts.category.ancestors.translations', 'location.city.translations', 'location.country.translations', ]); $tag = $model->tag; $tagName = $tag?->translation?->name ?? $tag?->name; $tagCategory = $tag?->contexts->first()?->category; $tagColor = $tagCategory?->relatedColor ?? 'gray'; $city = optional($model->location?->city?->translations->first())->name; $country = optional($model->location?->country?->translations->first())->name; $tagCategories = []; if ($tagCategory) { $ancestors = $tagCategory->ancestorsAndSelf()->get()->reverse(); $locale = app()->getLocale(); foreach ($ancestors as $cat) { $catName = $cat->translations->firstWhere('locale', $locale)?->name ?? $cat->translations->first()?->name ?? ''; if ($catName) { $tagCategories[] = [ 'name' => $catName, 'color' => $cat->relatedColor ?? 'gray', ]; } } } return [ 'id' => $model->id, 'model' => get_class($model), 'model_object' => $model, 'category_id' => null, 'category' => trans_with_platform('@PLATFORM_NAME@ call'), 'title' => $tagName ?? '', 'subtitle' => '', 'photo' => $model->callable?->profile_photo_url ?? '', 'location_short' => $city ?? '', 'location' => collect([$city, $country])->filter()->implode(', '), 'tag_name' => $tagName, 'tag_color' => $tagColor, 'tag_categories' => $tagCategories, 'callable_name' => $model->callable?->name ?? '', 'till' => $model->till, 'status' => '', 'location_proximity' => $this->calculateLocationProximity($model), ]; } private function processProfileCard($model): array { $canManageProfiles = $this->getCanManageProfiles(); if (!$canManageProfiles) { if ( timebank_config('profile_inactive.profile_search_hidden') && !empty($model->inactive_at) && \Carbon\Carbon::parse($model->inactive_at)->isPast() ) { return []; } if ( timebank_config('profile_email_unverified.profile_search_hidden') && empty($model->email_verified_at) ) { return []; } if ( !empty($model->deleted_at) && \Carbon\Carbon::parse($model->deleted_at)->isPast() ) { return []; } if ( timebank_config('profile_incomplete.profile_search_hidden') && method_exists($model, 'hasIncompleteProfile') && $model->hasIncompleteProfile($model) ) { return []; } } // Load locations only when needed if (!$model->relationLoaded('locations')) { $locations = $model->locations()->with([ 'district.translations', 'city.translations', 'division.translations', 'country' ])->get(); } else { $locations = $model->locations; } $firstLocation = $locations->first(); $locationShort = null; $location = ''; if ($firstLocation) { $city = null; $district = null; $division = null; $country = null; if ($firstLocation->city) { $cityTranslation = $firstLocation->city->translations->where('locale', app()->getLocale())->first(); $city = $cityTranslation ? $cityTranslation->name : $firstLocation->city->name; } if ($firstLocation->district) { $districtTranslation = $firstLocation->district->translations->where('locale', app()->getLocale())->first(); $district = $districtTranslation ? $districtTranslation->name : $firstLocation->district->name; } if ($firstLocation->division) { $divisionTranslation = $firstLocation->division->translations->where('locale', app()->getLocale())->first(); $division = $divisionTranslation ? $divisionTranslation->name : $firstLocation->division->name; } if (!$firstLocation->division && $city != null) { $cityId = $firstLocation->city->id; $cityModel = City::find($cityId); if ($cityModel && $cityModel->division) { $divisionTranslation = $cityModel->division->translations->where('locale', app()->getLocale())->first(); $division = $divisionTranslation ? $divisionTranslation->name : $cityModel->division->name; } } if ($firstLocation->country) { $country = $firstLocation->country->code; } if ($city) { $locationShort = $city . ', ' . $division; if ($district) { $location = $city . ' ' . $district . ', ' . $division . ', ' . $country; } else { $location = $city . ', ' . $division . ', ' . $country; } } else { if ($country && !$city) { $division ? $locationShort = $division . ', ' . $country : $locationShort = $country; $division ? $location = $division . ', ' . $country : $location = $country; } } } $status = ''; return [ 'id' => $model->id, 'model' => get_class($model), 'model_object' => $model, 'name' => $model->name ?? '', 'photo' => $model->profile_photo_url ?? '', 'location_short' => $locationShort ?? '', 'location' => $location, 'status' => $status, 'type' => strtolower(class_basename($model)), 'location_proximity' => $this->calculateLocationProximity($model), ]; } private function calculateLocationProximity($model): array { if (empty($this->locationHierarchy)) { return ['level' => 'unknown', 'distance' => null]; } $modelLocation = null; if (get_class($model) === \App\Models\Call::class) { $modelLocation = $model->location; if (!$modelLocation) { return ['level' => 'no_location', 'distance' => null]; } try { $modelHierarchy = $modelLocation->getCompleteHierarchy(); return $this->compareLocationHierarchies($modelHierarchy); } catch (\Exception $e) { return ['level' => 'error', 'distance' => null]; } } if (get_class($model) === 'App\Models\Post') { if ($model->category && $model->category->categoryable) { $categoryable = $model->category->categoryable; if (in_array(get_class($categoryable), [ 'App\Models\Locations\City', 'App\Models\Locations\District', 'App\Models\Locations\Division', 'App\Models\Locations\Country' ])) { try { $modelHierarchy = $this->buildLocationHierarchyFromCategoryable($categoryable); return $this->compareLocationHierarchies($modelHierarchy); } catch (\Exception $e) { return ['level' => 'error', 'distance' => null]; } } } return ['level' => 'no_location', 'distance' => null]; } else { $modelLocation = $model->locations->first(); } if (!$modelLocation) { return ['level' => 'no_location', 'distance' => null]; } try { $modelHierarchy = $modelLocation->getCompleteHierarchy(); return $this->compareLocationHierarchies($modelHierarchy); } catch (\Exception $e) { return ['level' => 'error', 'distance' => null]; } } private function buildLocationHierarchyFromCategoryable($categoryable): array { $hierarchy = [ 'country' => null, 'division' => null, 'city' => null, 'district' => null, ]; $categoryableClass = get_class($categoryable); switch ($categoryableClass) { case 'App\Models\Locations\Country': $hierarchy['country'] = $categoryable; break; case 'App\Models\Locations\Division': $hierarchy['division'] = $categoryable; $hierarchy['country'] = $categoryable->country ?? null; break; case 'App\Models\Locations\City': $hierarchy['city'] = $categoryable; $hierarchy['division'] = $categoryable->division ?? null; $hierarchy['country'] = $categoryable->country ?? null; break; case 'App\Models\Locations\District': $hierarchy['district'] = $categoryable; $hierarchy['city'] = $categoryable->city ?? null; $hierarchy['division'] = $categoryable->city->division ?? null; $hierarchy['country'] = $categoryable->city->country ?? null; break; } return $hierarchy; } private function compareLocationHierarchies($modelHierarchy): array { if ( isset($this->locationHierarchy['district'], $modelHierarchy['district']) && $this->locationHierarchy['district'] && $modelHierarchy['district'] && $this->locationHierarchy['district']->id === $modelHierarchy['district']->id ) { return ['level' => 'same_district', 'distance' => 1]; } if ( isset($this->locationHierarchy['city'], $modelHierarchy['city']) && $this->locationHierarchy['city'] && $modelHierarchy['city'] && $this->locationHierarchy['city']->id === $modelHierarchy['city']->id ) { return ['level' => 'same_city', 'distance' => 2]; } if ( isset($this->locationHierarchy['division'], $modelHierarchy['division']) && $this->locationHierarchy['division'] && $modelHierarchy['division'] && $this->locationHierarchy['division']->id === $modelHierarchy['division']->id ) { return ['level' => 'same_division', 'distance' => 3]; } if ( isset($this->locationHierarchy['country'], $modelHierarchy['country']) && $this->locationHierarchy['country'] && $modelHierarchy['country'] && $this->locationHierarchy['country']->id === $modelHierarchy['country']->id ) { return ['level' => 'same_country', 'distance' => 4]; } return ['level' => 'different_country', 'distance' => 5]; } private function getLocationProximityBoost(array $locationProximity): float { if (empty($locationProximity) || !isset($locationProximity['level'])) { return 1.0; } // Use config values for location boosts return timebank_config("search_optimization.location_boosts.{$locationProximity['level']}", 1.0); } /** * Processes the given search results and assigns a score to each result based on the cleaned search string. * * @param array $results The array of search results to process and score. * @param string $cleanSearch The cleaned search string used for scoring the results. * @return \Illuminate\Support\Collection A collection of processed and scored search results. */ private function processAndScoreResults(array $results, string $cleanSearch): \Illuminate\Support\Collection { // Track search analytics if enabled if (timebank_config('search_optimization.analytics.enabled')) { $startTime = microtime(true); } $extractedData = array_map(function ($result) use ($cleanSearch) { $type = strtolower(class_basename($result['model'])); $locationBoost = $this->getLocationProximityBoost($result['location_proximity'] ?? []); $originalScore = $result['score'] ?? 1; // Apply model boost only if SearchOptimizationHelper is disabled to avoid double boosting $modelBoost = 1; if (!timebank_config('search_optimization.enabled')) { $modelBoost = timebank_config("main_search_bar.boosted_models.{$type}", 1); } $boostedScore = $originalScore * $modelBoost * $locationBoost; // Use the proper location proximity system from logs and config $locationProximity = $result['location_proximity'] ?? []; $proximityLevel = $locationProximity['level'] ?? 'no_location'; // Convert level-based boosts to base scores for composite calculation $levelToScoreMap = [ 'same_district' => 100, // Highest priority 'same_city' => 60, // High priority 'same_division' => 30, // Medium priority 'same_country' => 10, // Low priority 'different_country' => 0, // No proximity bonus 'no_location' => 0, // No location bonus ]; $locationBaseScore = $levelToScoreMap[$proximityLevel] ?? 0; $normalizedScore = $boostedScore * 20; $compositeScore = $locationBaseScore + $normalizedScore; // Extract the field that produced the highlight for context $highlightField = $this->getHighlightField($result['highlight'] ?? []); return [ 'model' => $result['model'], 'id' => $result['id'] ?? '', 'score' => round($compositeScore / 10, 1), 'original_score' => $originalScore, 'highlight' => $result['highlight'] ?? [], 'highlight_field' => $highlightField, 'suggest' => $this->extractSuggestions($result['highlight'] ?? [], $cleanSearch), 'location_proximity' => $result['location_proximity'] ?? [], ]; }, $results); $sortedResults = collect($extractedData)->sortByDesc('score'); // Apply SearchOptimizationHelper if enabled if (timebank_config('search_optimization.enabled')) { $userLocation = $this->locationHierarchy; $optimized = SearchOptimizationHelper::optimizeSearchResults( $sortedResults->toArray(), explode(' ', $cleanSearch), $userLocation ); $sortedResults = collect($optimized); } // Track analytics if (timebank_config('search_optimization.analytics.enabled') && isset($startTime)) { $executionTime = microtime(true) - $startTime; SearchOptimizationHelper::trackLocationSearchAnalytics( [], // No specific category IDs in this search count($results), $executionTime, $this->locationHierarchy ); } return $sortedResults->take(timebank_config('main_search_bar.search.max_results', 50)); } /** * Get the field name that produced the highlight */ private function getHighlightField(array $highlights): string { if (empty($highlights)) { return 'unknown'; } // Check for advanced fields first $advancedFields = [ 'tags.contexts.tags.name_', 'tags.contexts.categories.name_' ]; foreach ($highlights as $field => $value) { foreach ($advancedFields as $advancedField) { if (strpos($field, $advancedField) !== false) { return 'advanced_field'; } } } // Return the first field if no advanced fields found return array_key_first($highlights) ?? 'unknown'; } /** * Extracts suggestion strings from the provided highlights array based on the search term. * * This method processes the highlights array to generate a list of suggestions * that are relevant to the given search term. The suggestions can be used for * features such as autocomplete or search recommendations. * * @param array $highlights An array containing highlight data from which suggestions will be extracted. * @param string $searchTerm The term that the user has entered to search for suggestions. * @return array An array of suggestion strings relevant to the search term. */ private function extractSuggestions(array $highlights, string $searchTerm): array { $suggestions = []; foreach ($highlights as $field => $fieldHighlights) { foreach ($fieldHighlights as $highlight) { // Remove highlight tags to get clean text $preTag = timebank_config('main_search_bar.search.pre-tags', ''); $postTag = timebank_config('main_search_bar.search.post-tags', ''); // Handle array format for tags if (is_array($preTag)) { $preTag = $preTag[0] ?? ''; } if (is_array($postTag)) { $postTag = $postTag[0] ?? ''; } $cleanText = str_replace([$preTag, $postTag], '', $highlight); // Split by sentences first $sentences = preg_split('/[.!?]+/', $cleanText); foreach ($sentences as $sentence) { $sentence = trim($sentence); // If sentence contains search term and is reasonable length if (stripos($sentence, $searchTerm) !== false && strlen($sentence) > 3) { // Limit sentence to max 5 words to keep suggestions readable $words = preg_split('/\s+/', $sentence); if (count($words) > 5) { // Find search term and keep 4 words around it $searchPosition = -1; for ($i = 0; $i < count($words); $i++) { if (stripos($words[$i], $searchTerm) !== false) { $searchPosition = $i; break; } } if ($searchPosition >= 0) { $start = max(0, $searchPosition - 2); $end = min(count($words) - 1, $start + 3); $sentence = implode(' ', array_slice($words, $start, $end - $start + 1)); } } $phrase = trim($sentence, '.,!?;:()[]{}'); if (strlen($phrase) > 7) { $suggestions[] = $phrase; } } } } } return array_slice(array_unique($suggestions), 0, 5); } /** * Limit highlights to only show the most relevant content field * Location fields are excluded from highlighting entirely */ private function limitHighlights(array $highlights): array { if (empty($highlights)) { return []; } // Priority order for content fields only (location fields no longer included in highlighting) $priorityFields = [ 'name', 'full_name', 'about_short_' . app()->getLocale(), 'about_' . app()->getLocale(), 'cyclos_skills', 'tags.contexts.tags.name_' . app()->getLocale(), 'tags.contexts.categories.name_' . app()->getLocale(), 'post_translations.title_' . app()->getLocale(), 'post_translations.excerpt_' . app()->getLocale(), 'post_translations.content_' . app()->getLocale(), 'post_category.names.name_' . app()->getLocale(), 'tag.name_' . app()->getLocale(), 'call_translations.content_' . app()->getLocale(), ]; // Return highlight from the highest priority field that has highlights foreach ($priorityFields as $field) { if (isset($highlights[$field]) && !empty($highlights[$field])) { return [$field => $highlights[$field]]; } } // Final fallback - return first available highlight (should not contain location fields now) $firstField = array_key_first($highlights); return $firstField ? [$firstField => $highlights[$firstField]] : []; } /** * CRITICAL SECURITY METHOD - XSS PROTECTION * * Sanitize highlights to prevent XSS attacks while preserving Elasticsearch highlight tags. * * This method ensures that user-generated content in search highlights is properly escaped, * while allowing only the configured highlight wrapper tags (e.g., tags used by Elasticsearch). * * SECURITY VULNERABILITY FIXED: * - User-generated content from profile fields (about, about_short, motivation, website, skills) * - Gets indexed in Elasticsearch and returned in search highlights * - Without sanitization, malicious HTML/JavaScript executes in victim's browser * - This method escapes ALL user content while preserving only safe highlight tags * * HOW THIS PROTECTION CAN BE BROKEN: * 1. DANGER: If you bypass this method and pass highlights directly to the view * 2. DANGER: If you modify the placeholder strings to match actual user content * 3. DANGER: If you change config pre/post tags to include JavaScript event handlers (e.g., onclick=) * 4. DANGER: If you use str_replace with user-controlled input for placeholders * 5. DANGER: If you cache highlights BEFORE sanitization (must cache AFTER) * 6. DANGER: If you add additional HTML tags to the whitelist without careful review * 7. DANGER: If you use {!! !!} syntax on highlights from a different source without sanitization * * SAFE USAGE: * - REQUIRED: Always call this method before caching or displaying highlights * - REQUIRED: Only use {!! !!} syntax on highlights that have passed through this method * - REQUIRED: Keep pre/post tags simple HTML without attributes that can execute code * - REQUIRED: Use unique placeholder strings that cannot appear in user input * * @param array $highlights Array of highlight strings from Elasticsearch * @return array Sanitized highlights safe for HTML output with {!! !!} */ private function sanitizeHighlights(array $highlights): array { if (empty($highlights)) { return []; } // SECURITY: Get configured highlight tags - these must be reviewed if changed // Only simple HTML tags without event handlers are safe (e.g., , not ) $preTag = timebank_config('main_search_bar.search.pre-tags', ''); $postTag = timebank_config('main_search_bar.search.post-tags', ''); $sanitized = []; foreach ($highlights as $field => $highlightArray) { $sanitized[$field] = []; foreach ($highlightArray as $highlightText) { // SECURITY STEP 1: Temporarily replace Elasticsearch highlight tags with unique placeholders // Placeholders must be unique strings that cannot appear in user input $placeholder_pre = '___HIGHLIGHT_START___'; $placeholder_post = '___HIGHLIGHT_END___'; $safe = str_replace($preTag, $placeholder_pre, $highlightText); $safe = str_replace($postTag, $placeholder_post, $safe); // SECURITY STEP 2: Escape ALL HTML entities to prevent XSS // This converts < > & " ' to HTML entities, making them harmless // ENT_QUOTES escapes both single and double quotes // ENT_HTML5 ensures proper HTML5 entity encoding $safe = htmlspecialchars($safe, ENT_QUOTES | ENT_HTML5, 'UTF-8'); // SECURITY STEP 3: Restore ONLY the safe Elasticsearch highlight tags // At this point, any malicious HTML is escaped, so restoring our known-safe tags is safe $safe = str_replace($placeholder_pre, $preTag, $safe); $safe = str_replace($placeholder_post, $postTag, $safe); $sanitized[$field][] = $safe; } } return $sanitized; } public function showSearchResults() { if (strlen($this->search) > 1) { $searchTerm = $this->search; $total = $this->total; $this->results = $this->fetchedResults; $key = 'main_search_bar_results_' . Auth::guard('web')->id(); $results = collect($this->results)->map(fn ($r) => [ 'model' => $r['model'], 'id' => $r['id'], 'highlight' => $r['highlight'] ?? [], 'score' => isset($r['score']) ? round($r['score'] / 10, 1) : null, 'location_proximity' => $r['location_proximity'] ?? [], ])->values()->all(); cache()->put($key, ['results' => $results, 'searchTerm' => $searchTerm, 'total' => $total], now()->addMinutes(timebank_config('main_search_bar.cache_results', 5))); return redirect()->route('search.show'); } else { return null; } } }