View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.helper;
17  
18  import java.io.ByteArrayInputStream;
19  import java.io.ByteArrayOutputStream;
20  import java.io.IOException;
21  import java.nio.charset.StandardCharsets;
22  import java.text.NumberFormat;
23  import java.util.ArrayList;
24  import java.util.Arrays;
25  import java.util.Base64;
26  import java.util.Enumeration;
27  import java.util.HashMap;
28  import java.util.HashSet;
29  import java.util.List;
30  import java.util.Locale;
31  import java.util.Map;
32  import java.util.Map.Entry;
33  import java.util.Set;
34  import java.util.concurrent.ExecutionException;
35  import java.util.function.Consumer;
36  import java.util.stream.Collectors;
37  import java.util.zip.GZIPInputStream;
38  import java.util.zip.GZIPOutputStream;
39  
40  import org.apache.logging.log4j.LogManager;
41  import org.apache.logging.log4j.Logger;
42  import org.codelibs.core.exception.IORuntimeException;
43  import org.codelibs.core.exception.InterruptedRuntimeException;
44  import org.codelibs.core.lang.StringUtil;
45  import org.codelibs.core.stream.StreamUtil;
46  import org.codelibs.fess.Constants;
47  import org.codelibs.fess.entity.QueryContext;
48  import org.codelibs.fess.entity.RequestParameter;
49  import org.codelibs.fess.entity.SearchRenderData;
50  import org.codelibs.fess.entity.SearchRequestParams;
51  import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType;
52  import org.codelibs.fess.exception.InvalidQueryException;
53  import org.codelibs.fess.exception.SearchQueryException;
54  import org.codelibs.fess.mylasta.action.FessUserBean;
55  import org.codelibs.fess.mylasta.direction.FessConfig;
56  import org.codelibs.fess.opensearch.client.SearchEngineClient.SearchConditionBuilder;
57  import org.codelibs.fess.opensearch.client.SearchEngineClientException;
58  import org.codelibs.fess.query.QueryFieldConfig;
59  import org.codelibs.fess.rank.fusion.RankFusionProcessor;
60  import org.codelibs.fess.util.BooleanFunction;
61  import org.codelibs.fess.util.ComponentUtil;
62  import org.codelibs.fess.util.QueryResponseList;
63  import org.dbflute.optional.OptionalEntity;
64  import org.dbflute.optional.OptionalThing;
65  import org.dbflute.util.DfTypeUtil;
66  import org.lastaflute.taglib.function.LaFunctions;
67  import org.lastaflute.web.util.LaRequestUtil;
68  import org.lastaflute.web.util.LaResponseUtil;
69  import org.opensearch.OpenSearchException;
70  import org.opensearch.action.DocWriteResponse.Result;
71  import org.opensearch.action.bulk.BulkRequestBuilder;
72  import org.opensearch.action.bulk.BulkResponse;
73  import org.opensearch.action.update.UpdateRequestBuilder;
74  import org.opensearch.action.update.UpdateResponse;
75  import org.opensearch.common.document.DocumentField;
76  import org.opensearch.index.query.BoolQueryBuilder;
77  import org.opensearch.index.query.QueryBuilders;
78  
79  import com.fasterxml.jackson.databind.ObjectMapper;
80  
81  import jakarta.servlet.http.Cookie;
82  import jakarta.servlet.http.HttpServletRequest;
83  
84  /**
85   * Helper class for handling search operations in Fess.
86   *
87   * This class provides comprehensive search functionality including document search,
88   * scroll search, and bulk operations. It handles search request parameter processing,
89   * query building, response formatting, and search log management.
90   *
91   * Key features:
92   * - Document search with pagination and faceting
93   * - Scroll search for large result sets
94   * - Document retrieval by ID
95   * - Bulk document updates
96   * - Search parameter serialization/deserialization for cookies
97   * - Integration with search engines and logging systems
98   */
99  public class SearchHelper {
100 
101     // ===================================================================================
102     //                                                                            Constant
103     //
104 
105     /** Logger for this class. */
106     private static final Logger logger = LogManager.getLogger(SearchHelper.class);
107 
108     // ===================================================================================
109     //                                                                            Variable
110     //
111 
112     /** Array of search request parameter rewriters for modifying search parameters. */
113     protected SearchRequestParamsRewriter[] searchRequestParamsRewriters = {};
114 
115     /** Jackson ObjectMapper for JSON serialization/deserialization. */
116     protected ObjectMapper mapper = new ObjectMapper();;
117 
118     /**
119      * Default constructor for creating a new SearchHelper instance.
120      */
121     public SearchHelper() {
122         // Default constructor
123     }
124 
125     // ===================================================================================
126     //                                                                              Method
127     //                                                                      ==============
128 
129     /**
130      * Performs a search operation and populates the search render data with results.
131      *
132      * This method handles the complete search workflow including parameter processing,
133      * query execution, result formatting, and logging. It supports automatic retry
134      * with escaped queries if the initial search fails.
135      *
136      * @param searchRequestParams The search request parameters
137      * @param data The search render data to populate with results
138      * @param userBean Optional user information for permission checking
139      */
140     public void search(final SearchRequestParams searchRequestParams, final SearchRenderData data,
141             final OptionalThing<FessUserBean> userBean) {
142         final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
143         final long startTime = systemHelper.getCurrentTimeAsLong();
144         final long requestedTime = startTime;
145 
146         final SearchRequestParams params = rewrite(searchRequestParams);
147 
148         LaRequestUtil.getOptionalRequest().ifPresent(request -> {
149             request.setAttribute(Constants.REQUEST_LANGUAGES, params.getLanguages());
150             request.setAttribute(Constants.REQUEST_QUERIES, params.getQuery());
151         });
152 
153         String query = ComponentUtil.getQueryStringBuilder().params(params).sortField(params.getSort()).build();
154         List<Map<String, Object>> documentItems;
155         try {
156             documentItems = searchInternal(query, params, userBean);
157         } catch (final InvalidQueryException e) {
158             if (logger.isDebugEnabled()) {
159                 logger.debug("Invalid query: {}", query, e);
160             }
161             query = ComponentUtil.getQueryStringBuilder().params(params).sortField(params.getSort()).escape(true).build();
162             documentItems = searchInternal(query, params, userBean);
163         }
164 
165         data.setDocumentItems(documentItems);
166 
167         // search
168         final QueryResponseList queryResponseList = (QueryResponseList) documentItems;
169         data.setFacetResponse(queryResponseList.getFacetResponse());
170 
171         @SuppressWarnings("unchecked")
172         final Set<String> highlightQueries = (Set<String>) params.getAttribute(Constants.HIGHLIGHT_QUERIES);
173         if (highlightQueries != null) {
174             final StringBuilder buf = new StringBuilder(100);
175             highlightQueries.stream().forEach(q -> {
176                 buf.append("&hq=").append(LaFunctions.u(q));
177             });
178             data.setAppendHighlightParams(buf.toString());
179         }
180 
181         queryResponseList.setExecTime(systemHelper.getCurrentTimeAsLong() - startTime);
182         final NumberFormat nf = NumberFormat.getInstance(params.getLocale());
183         nf.setMaximumIntegerDigits(2);
184         nf.setMaximumFractionDigits(2);
185         String execTime;
186         try {
187             execTime = nf.format((double) queryResponseList.getExecTime() / 1000);
188         } catch (final Exception e) {
189             execTime = StringUtil.EMPTY;
190         }
191         data.setExecTime(execTime);
192 
193         final String queryId = ComponentUtil.getQueryHelper().generateId();
194 
195         data.setPageSize(queryResponseList.getPageSize());
196         data.setCurrentPageNumber(queryResponseList.getCurrentPageNumber());
197         data.setAllRecordCount(queryResponseList.getAllRecordCount());
198         data.setAllRecordCountRelation(queryResponseList.getAllRecordCountRelation());
199         data.setAllPageCount(queryResponseList.getAllPageCount());
200         data.setExistNextPage(queryResponseList.isExistNextPage());
201         data.setExistPrevPage(queryResponseList.isExistPrevPage());
202         data.setCurrentStartRecordNumber(queryResponseList.getCurrentStartRecordNumber());
203         data.setCurrentEndRecordNumber(queryResponseList.getCurrentEndRecordNumber());
204         data.setPageNumberList(queryResponseList.getPageNumberList());
205         data.setPartialResults(queryResponseList.isPartialResults());
206         data.setQueryTime(queryResponseList.getQueryTime());
207         data.setSearchQuery(query);
208         data.setRequestedTime(requestedTime);
209         data.setQueryId(queryId);
210 
211         final FessConfig fessConfig = ComponentUtil.getFessConfig();
212 
213         // search log
214         if (fessConfig.isSearchLog()) {
215             ComponentUtil.getSearchLogHelper()
216                     .addSearchLog(params, DfTypeUtil.toLocalDateTime(requestedTime), queryId, query, params.getStartPosition(),
217                             params.getPageSize(), queryResponseList);
218         }
219 
220         // favorite
221         if (fessConfig.isUserFavorite()) {
222             ComponentUtil.getUserInfoHelper().storeQueryId(queryId, documentItems);
223         }
224 
225     }
226 
227     /**
228      * Internal search method that executes the actual search query.
229      *
230      * This method performs the search using the rank fusion processor and may retry
231      * with OR operator if the hit count is below the configured minimum threshold.
232      *
233      * @param query The search query string
234      * @param params The search request parameters
235      * @param userBean Optional user information for permission checking
236      * @return List of search result documents
237      */
238     protected List<Map<String, Object>> searchInternal(final String query, final SearchRequestParams params,
239             final OptionalThing<FessUserBean> userBean) {
240         final RankFusionProcessor rankFusionProcessor = ComponentUtil.getRankFusionProcessor();
241         final List<Map<String, Object>> documentItems = rankFusionProcessor.search(query, params, userBean);
242         if (documentItems instanceof final QueryResponseList queryResponseList) {
243             final FessConfig fessConfig = ComponentUtil.getFessConfig();
244             if (queryResponseList.getAllRecordCount() <= fessConfig.getQueryOrsearchMinHitCountAsInteger()) {
245                 return LaRequestUtil.getOptionalRequest().map(request -> {
246                     request.setAttribute(Constants.DEFAULT_QUERY_OPERATOR, "OR");
247                     if (logger.isDebugEnabled()) {
248                         logger.debug("The number of hits is {}<={}. Searching again with OR operator.",
249                                 queryResponseList.getAllRecordCount(), fessConfig.getQueryOrsearchMinHitCountAsInteger());
250                     }
251                     return rankFusionProcessor.search(query, params, userBean);
252                 }).orElse(queryResponseList);
253             }
254         }
255         return documentItems;
256     }
257 
258     /**
259      * Performs a scroll search for processing large result sets efficiently.
260      *
261      * This method uses OpenSearch scroll API to iterate through large numbers of
262      * documents without loading them all into memory at once.
263      *
264      * @param params The search request parameters
265      * @param cursor Function to process each document in the result set
266      * @param userBean Optional user information for permission checking
267      * @return Total number of documents processed
268      */
269     public long scrollSearch(final SearchRequestParams params, final BooleanFunction<Map<String, Object>> cursor,
270             final OptionalThing<FessUserBean> userBean) {
271         LaRequestUtil.getOptionalRequest().ifPresent(request -> {
272             request.setAttribute(Constants.REQUEST_LANGUAGES, params.getLanguages());
273             request.setAttribute(Constants.REQUEST_QUERIES, params.getQuery());
274         });
275 
276         final int pageSize = params.getPageSize();
277         final String query = ComponentUtil.getQueryStringBuilder().params(params).sortField(params.getSort()).build();
278         final FessConfig fessConfig = ComponentUtil.getFessConfig();
279         return ComponentUtil.getSearchEngineClient()
280                 .<Map<String, Object>> scrollSearch(fessConfig.getIndexDocumentSearchIndex(), searchRequestBuilder -> {
281                     final QueryHelper queryHelper = ComponentUtil.getQueryHelper();
282                     final QueryFieldConfig queryFieldConfig = ComponentUtil.getQueryFieldConfig();
283                     queryHelper.processSearchPreference(searchRequestBuilder, userBean, query);
284                     return SearchConditionBuilder.builder(searchRequestBuilder)
285                             .scroll()
286                             .query(query)
287                             .size(pageSize)
288                             .responseFields(queryFieldConfig.getScrollResponseFields())
289                             .searchRequestType(params.getType())
290                             .build();
291                 }, (searchResponse, hit) -> {
292                     final Map<String, Object> docMap = new HashMap<>();
293                     final Map<String, Object> source = hit.getSourceAsMap();
294                     if (source != null) {
295                         docMap.putAll(source);
296                     }
297                     final Map<String, DocumentField> fields = hit.getFields();
298                     if (fields != null) {
299                         docMap.putAll(fields.entrySet()
300                                 .stream()
301                                 .collect(Collectors.toMap(Entry::getKey, e -> (Object) e.getValue().getValues())));
302                     }
303 
304                     final ViewHelper viewHelper = ComponentUtil.getViewHelper();
305                     if (viewHelper != null && !docMap.isEmpty()) {
306                         docMap.put(fessConfig.getResponseFieldContentTitle(), viewHelper.getContentTitle(docMap));
307                         docMap.put(fessConfig.getResponseFieldContentDescription(), viewHelper.getContentDescription(docMap));
308                         docMap.put(fessConfig.getResponseFieldUrlLink(), viewHelper.getUrlLink(docMap));
309                         docMap.put(fessConfig.getResponseFieldSitePath(), viewHelper.getSitePath(docMap));
310                     }
311 
312                     if (!docMap.containsKey(Constants.SCORE)) {
313                         final float score = hit.getScore();
314                         if (Float.isFinite(score)) {
315                             docMap.put(Constants.SCORE, score);
316                         }
317                     }
318 
319                     docMap.put(fessConfig.getIndexFieldId(), hit.getId());
320                     docMap.put(fessConfig.getIndexFieldVersion(), hit.getVersion());
321                     docMap.put(fessConfig.getIndexFieldSeqNo(), hit.getSeqNo());
322                     docMap.put(fessConfig.getIndexFieldPrimaryTerm(), hit.getPrimaryTerm());
323                     return docMap;
324                 }, cursor);
325     }
326 
327     /**
328      * Deletes documents matching the specified search parameters.
329      *
330      * @param request The HTTP servlet request
331      * @param params The search request parameters to identify documents to delete
332      * @return Number of documents deleted
333      */
334     public long deleteByQuery(final HttpServletRequest request, final SearchRequestParams params) {
335         final String query = ComponentUtil.getQueryStringBuilder().params(params).build();
336 
337         final QueryContext queryContext = ComponentUtil.getQueryHelper().build(params.getType(), query, context -> {
338             context.skipRoleQuery();
339         });
340         return ComponentUtil.getSearchEngineClient()
341                 .deleteByQuery(ComponentUtil.getFessConfig().getIndexDocumentUpdateIndex(), queryContext.getQueryBuilder());
342     }
343 
344     /**
345      * Extracts and normalizes language preferences from request parameters or browser locale.
346      *
347      * This method prioritizes explicit language parameters over browser locale settings
348      * and handles special cases like "all languages" selection.
349      *
350      * @param request The HTTP servlet request
351      * @param params The search request parameters
352      * @return Array of normalized language codes
353      */
354     public String[] getLanguages(final HttpServletRequest request, final SearchRequestParams params) {
355         final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
356         if (params.getLanguages() != null) {
357             final Set<String> langSet = new HashSet<>();
358             for (final String lang : params.getLanguages()) {
359                 if (StringUtil.isNotBlank(lang) && lang.length() < 1000) {
360                     if (Constants.ALL_LANGUAGES.equalsIgnoreCase(lang)) {
361                         langSet.add(Constants.ALL_LANGUAGES);
362                     } else {
363                         final String normalizeLang = systemHelper.normalizeLang(lang);
364                         if (normalizeLang != null) {
365                             langSet.add(normalizeLang);
366                         }
367                     }
368                 }
369             }
370             if (langSet.size() > 1 && langSet.contains(Constants.ALL_LANGUAGES)) {
371                 return new String[] { Constants.ALL_LANGUAGES };
372             }
373             langSet.remove(Constants.ALL_LANGUAGES);
374             return langSet.toArray(new String[langSet.size()]);
375         }
376         if (ComponentUtil.getFessConfig().isBrowserLocaleForSearchUsed()) {
377             final Set<String> langSet = new HashSet<>();
378             final Enumeration<Locale> locales = request.getLocales();
379             if (locales != null) {
380                 while (locales.hasMoreElements()) {
381                     final Locale locale = locales.nextElement();
382                     final String normalizeLang = systemHelper.normalizeLang(locale.toString());
383                     if (normalizeLang != null) {
384                         langSet.add(normalizeLang);
385                     }
386                 }
387                 if (!langSet.isEmpty()) {
388                     return langSet.toArray(new String[langSet.size()]);
389                 }
390             }
391         }
392         return StringUtil.EMPTY_STRINGS;
393     }
394 
395     /**
396      * Retrieves a single document by its document ID.
397      *
398      * @param docId The document ID to retrieve
399      * @param fields Array of field names to include in the result
400      * @param userBean Optional user information for permission checking
401      * @return Optional entity containing the document data if found
402      */
403     public OptionalEntity<Map<String, Object>> getDocumentByDocId(final String docId, final String[] fields,
404             final OptionalThing<FessUserBean> userBean) {
405         final FessConfig fessConfig = ComponentUtil.getFessConfig();
406         return ComponentUtil.getSearchEngineClient().getDocument(fessConfig.getIndexDocumentSearchIndex(), builder -> {
407             final BoolQueryBuilder boolQuery =
408                     QueryBuilders.boolQuery().must(QueryBuilders.termQuery(fessConfig.getIndexFieldDocId(), docId));
409             final Set<String> roleSet = ComponentUtil.getRoleQueryHelper().build(SearchRequestType.JSON); // TODO SearchRequestType?
410             final QueryHelper queryHelper = ComponentUtil.getQueryHelper();
411             if (!roleSet.isEmpty()) {
412                 queryHelper.buildRoleQuery(roleSet, boolQuery);
413             }
414             builder.setQuery(boolQuery);
415             builder.setFetchSource(fields, null);
416             queryHelper.processSearchPreference(builder, userBean, docId);
417             return true;
418         });
419 
420     }
421 
422     /**
423      * Retrieves multiple documents by their document IDs.
424      *
425      * @param docIds Array of document IDs to retrieve
426      * @param fields Array of field names to include in the results
427      * @param userBean Optional user information for permission checking
428      * @param searchRequestType Type of search request for role-based access control
429      * @return List of document data maps
430      */
431     public List<Map<String, Object>> getDocumentListByDocIds(final String[] docIds, final String[] fields,
432             final OptionalThing<FessUserBean> userBean, final SearchRequestType searchRequestType) {
433         final FessConfig fessConfig = ComponentUtil.getFessConfig();
434         return ComponentUtil.getSearchEngineClient().getDocumentList(fessConfig.getIndexDocumentSearchIndex(), builder -> {
435             final BoolQueryBuilder boolQuery =
436                     QueryBuilders.boolQuery().must(QueryBuilders.termsQuery(fessConfig.getIndexFieldDocId(), docIds));
437             final QueryHelper queryHelper = ComponentUtil.getQueryHelper();
438             if (searchRequestType != SearchRequestType.ADMIN_SEARCH) {
439                 final Set<String> roleSet = ComponentUtil.getRoleQueryHelper().build(searchRequestType);
440                 if (!roleSet.isEmpty()) {
441                     queryHelper.buildRoleQuery(roleSet, boolQuery);
442                 }
443             }
444             builder.setQuery(boolQuery);
445             builder.setSize(fessConfig.getPagingSearchPageMaxSizeAsInteger());
446             builder.setFetchSource(fields, null);
447             queryHelper.processSearchPreference(builder, userBean, String.join(StringUtil.EMPTY, docIds));
448             return true;
449         });
450     }
451 
452     /**
453      * Updates a single field of a document.
454      *
455      * @param id The document ID to update
456      * @param field The field name to update
457      * @param value The new value for the field
458      * @return true if the update was successful, false otherwise
459      */
460     public boolean update(final String id, final String field, final Object value) {
461         return ComponentUtil.getSearchEngineClient().update(ComponentUtil.getFessConfig().getIndexDocumentUpdateIndex(), id, field, value);
462     }
463 
464     /**
465      * Updates a document using a custom update request builder.
466      *
467      * @param id The document ID to update
468      * @param builderLambda Consumer function to configure the update request builder
469      * @return true if the update was successful, false otherwise
470      */
471     public boolean update(final String id, final Consumer<UpdateRequestBuilder> builderLambda) {
472         try {
473             final FessConfig fessConfig = ComponentUtil.getFessConfig();
474             final UpdateRequestBuilder builder =
475                     ComponentUtil.getSearchEngineClient().prepareUpdate().setIndex(fessConfig.getIndexDocumentUpdateIndex()).setId(id);
476             builderLambda.accept(builder);
477             final UpdateResponse response = builder.execute().actionGet(fessConfig.getIndexIndexTimeout());
478             return response.getResult() == Result.CREATED || response.getResult() == Result.UPDATED;
479         } catch (final OpenSearchException e) {
480             throw new SearchEngineClientException("Failed to update doc  " + id, e);
481         }
482     }
483 
484     /**
485      * Performs bulk update operations using a custom bulk request builder.
486      *
487      * @param consumer Consumer function to configure the bulk request builder
488      * @return true if all bulk operations were successful, false otherwise
489      * @throws InterruptedRuntimeException if the operation is interrupted
490      * @throws SearchEngineClientException if the bulk update fails
491      */
492     public boolean bulkUpdate(final Consumer<BulkRequestBuilder> consumer) {
493         final BulkRequestBuilder builder = ComponentUtil.getSearchEngineClient().prepareBulk();
494         consumer.accept(builder);
495         try {
496             final BulkResponse response = builder.execute().get();
497             if (response.hasFailures()) {
498                 throw new SearchEngineClientException(response.buildFailureMessage());
499             }
500             return true;
501         } catch (final InterruptedException e) {
502             throw new InterruptedRuntimeException(e);
503         } catch (final ExecutionException e) {
504             throw new SearchEngineClientException("Failed to update bulk data.", e);
505         }
506     }
507 
508     /**
509      * Applies registered parameter rewriters to modify search request parameters.
510      *
511      * @param params The original search request parameters
512      * @return Modified search request parameters after applying all rewriters
513      */
514     protected SearchRequestParams rewrite(final SearchRequestParams params) {
515         SearchRequestParams newParams = params;
516         for (final SearchRequestParamsRewriter rewriter : searchRequestParamsRewriters) {
517             newParams = rewriter.rewrite(newParams);
518         }
519         return newParams;
520     }
521 
522     /**
523      * Adds a search request parameter rewriter to the list of active rewriters.
524      *
525      * @param rewriter The parameter rewriter to add
526      */
527     public void addRewriter(final SearchRequestParamsRewriter rewriter) {
528         searchRequestParamsRewriters = Arrays.copyOf(searchRequestParamsRewriters, searchRequestParamsRewriters.length + 1);
529         searchRequestParamsRewriters[searchRequestParamsRewriters.length - 1] = rewriter;
530     }
531 
532     /**
533      * Stores current search parameters in a browser cookie for later retrieval.
534      *
535      * This method serializes the current request parameters, compresses them using GZIP,
536      * encodes them with Base64, and stores them in a secure HTTP cookie.
537      */
538     public void storeSearchParameters() {
539         LaRequestUtil.getOptionalRequest().ifPresent(req -> {
540             final FessConfig fessConfig = ComponentUtil.getFessConfig();
541             final String requiredKeysStr = fessConfig.getCookieSearchParameterRequiredKeys();
542             if (StringUtil.isNotBlank(requiredKeysStr) && StreamUtil.split(requiredKeysStr, ",")
543                     .get(stream -> stream.map(String::trim).filter(StringUtil::isNotEmpty).anyMatch(name -> {
544                         final String[] values = req.getParameterValues(name);
545                         if (values == null || values.length == 0 || StringUtil.isEmpty(values[0])) {
546                             if (logger.isDebugEnabled()) {
547                                 logger.debug("Required parameter '{}' is missing or empty. Skip storing search parameters.", name);
548                             }
549                             return true;
550                         }
551                         return false;
552                     }))) {
553                 return;
554             }
555             final String keysStr = fessConfig.getCookieSearchParameterKeys();
556             if (StringUtil.isNotBlank(keysStr)) {
557                 final RequestParameter[] parameters = StreamUtil.split(keysStr, ",").get(stream -> stream.map(String::trim).map(s -> {
558                     if (StringUtil.isEmpty(s)) {
559                         return null;
560                     }
561                     final String[] values = req.getParameterValues(s);
562                     if (values == null || values.length == 0) {
563                         if (logger.isDebugEnabled()) {
564                             logger.debug("Parameter '{}' is not present or has no value.", s);
565                         }
566                         return null;
567                     }
568                     return new RequestParameter(s, values);
569                 }).filter(o -> o != null).toArray(n -> new RequestParameter[n]));
570                 if (parameters.length == 0) {
571                     if (logger.isDebugEnabled()) {
572                         logger.debug("No valid parameters found in request. Nothing to store.");
573                     }
574                     return;
575                 }
576                 try {
577                     final String encoded = serializeParameters(parameters);
578                     if (encoded.length() > fessConfig.getCookieSearchParameterMaxLengthAsInteger()) {
579                         logger.warn("Encoded search parameters exceed the maximum cookie length: {} > {}. Skipping cookie storage.",
580                                 encoded.length(), fessConfig.getCookieSearchParameterMaxLengthAsInteger());
581                         return;
582                     }
583                     LaResponseUtil.getOptionalResponse().ifPresent(res -> {
584                         final Cookie cookie = new Cookie(fessConfig.getCookieSearchParameterName(), encoded);
585                         cookie.setHttpOnly(Constants.TRUE.equalsIgnoreCase(fessConfig.getCookieSearchParameterHttpOnly()));
586                         cookie.setSecure(isSearchParameterCookieSecure(req));
587                         final String domain = fessConfig.getCookieSearchParameterDomain();
588                         if (StringUtil.isNotBlank(domain)) {
589                             cookie.setDomain(domain);
590                         }
591                         final String path = fessConfig.getCookieSearchParameterPath();
592                         if (StringUtil.isNotBlank(path)) {
593                             cookie.setPath(path);
594                         }
595                         cookie.setMaxAge(fessConfig.getCookieSearchParameterMaxAgeAsInteger());
596                         cookie.setAttribute("SameSite", fessConfig.getCookieSearchParameterSameSite());
597                         res.addCookie(cookie);
598                         if (logger.isDebugEnabled()) {
599                             logger.debug(
600                                     "Stored search parameters in cookie: name={}, size={}, maxAge={}, path={}, domain={}, secure={}, httpOnly={}, sameSite={}",
601                                     cookie.getName(), encoded.length(), cookie.getMaxAge(), cookie.getPath(), cookie.getDomain(),
602                                     cookie.getSecure(), cookie.isHttpOnly(), fessConfig.getCookieSearchParameterSameSite());
603                         }
604                     });
605                 } catch (final Exception e) {
606                     logger.warn("Failed to store search parameters in cookie.", e);
607                 }
608             }
609         });
610     }
611 
612     /**
613      * Serializes request parameters to a compressed and encoded string.
614      *
615      * @param parameters Array of request parameters to serialize
616      * @return Base64-encoded, GZIP-compressed JSON string of parameters
617      * @throws SearchQueryException if serialization fails
618      */
619     protected String serializeParameters(final RequestParameter[] parameters) {
620         final List<Object[]> compactList = new ArrayList<>();
621         for (final RequestParameter p : parameters) {
622             compactList.add(new Object[] { p.getName(), p.getValues() });
623         }
624         try {
625             final String json = mapper.writeValueAsString(compactList);
626             final byte[] compressed = gzipCompress(json.getBytes(StandardCharsets.UTF_8));
627             return Base64.getUrlEncoder().withoutPadding().encodeToString(compressed);
628         } catch (final Exception e) {
629             throw new SearchQueryException("Failed to serialize a query: " + Arrays.toString(parameters), e);
630         }
631     }
632 
633     /**
634      * Compresses data using GZIP compression.
635      *
636      * @param data The data to compress
637      * @return GZIP-compressed data
638      * @throws IORuntimeException if compression fails
639      */
640     protected byte[] gzipCompress(final byte[] data) {
641         try (final ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
642             try (final GZIPOutputStream gzipOut = new GZIPOutputStream(bos)) {
643                 gzipOut.write(data);
644             }
645             return bos.toByteArray();
646         } catch (final IOException e) {
647             throw new IORuntimeException(e);
648         }
649     }
650 
651     /**
652      * Retrieves and deserializes search parameters from browser cookies.
653      *
654      * @return Array of request parameters from the cookie, or empty array if none found
655      */
656     public RequestParameter[] getSearchParameters() {
657         return LaRequestUtil.getOptionalRequest().map(req -> {
658             final FessConfig fessConfig = ComponentUtil.getFessConfig();
659             final String cookieName = fessConfig.getCookieSearchParameterName();
660             final Cookie[] cookies = req.getCookies();
661             if (cookies != null) {
662                 for (final Cookie cookie : cookies) {
663                     if (cookieName.equals(cookie.getName())) {
664                         try {
665                             final String encoded = cookie.getValue();
666                             final byte[] compressed = Base64.getUrlDecoder().decode(encoded);
667                             final byte[] jsonBytes = gzipDecompress(compressed);
668                             final List<?> list = mapper.readValue(jsonBytes, List.class);
669 
670                             final List<RequestParameter> result = new ArrayList<>();
671                             for (Object item : list) {
672                                 if (item instanceof List<?> pair) {
673                                     if (pair.size() == 2 && pair.get(0) instanceof String name
674                                             && pair.get(1) instanceof List<?> valueList) {
675                                         final String[] values =
676                                                 valueList.stream().filter(v -> v instanceof String).toArray(n -> new String[n]);
677                                         result.add(new RequestParameter(name, values));
678                                     }
679                                 }
680                             }
681                             LaResponseUtil.getOptionalResponse().ifPresent(res -> {
682                                 final Cookie invalidCookie = new Cookie(fessConfig.getCookieSearchParameterName(), StringUtil.EMPTY);
683                                 invalidCookie.setHttpOnly(Constants.TRUE.equalsIgnoreCase(fessConfig.getCookieSearchParameterHttpOnly()));
684                                 invalidCookie.setSecure(isSearchParameterCookieSecure(req));
685                                 invalidCookie.setPath(fessConfig.getCookieSearchParameterPath());
686                                 invalidCookie.setMaxAge(0);
687                                 res.addCookie(invalidCookie);
688                             });
689                             return result.toArray(n -> new RequestParameter[n]);
690                         } catch (final Exception e) {
691                             logger.warn("Failed to deserialize search parameters from cookie.", e);
692                             return new RequestParameter[0];
693                         }
694                     }
695                 }
696             }
697             return new RequestParameter[0];
698         }).orElse(new RequestParameter[0]);
699     }
700 
701     /**
702      * Decompresses GZIP-compressed data.
703      *
704      * @param compressed The GZIP-compressed data to decompress
705      * @return Decompressed data
706      * @throws IORuntimeException if decompression fails
707      */
708     protected byte[] gzipDecompress(final byte[] compressed) {
709         try (final ByteArrayInputStream bis = new ByteArrayInputStream(compressed);
710                 final GZIPInputStream gzipIn = new GZIPInputStream(bis);
711                 final ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
712             final byte[] buffer = new byte[1024];
713             int len;
714             while ((len = gzipIn.read(buffer)) > 0) {
715                 bos.write(buffer, 0, len);
716             }
717             return bos.toByteArray();
718         } catch (final IOException e) {
719             throw new IORuntimeException(e);
720         }
721     }
722 
723     /**
724      * Determines if the search parameter cookie should be secure based on configuration and request.
725      *
726      * @param request The HTTP request
727      * @return true if the cookie should have the secure flag set
728      */
729     protected boolean isSearchParameterCookieSecure(final HttpServletRequest request) {
730         final FessConfig fessConfig = ComponentUtil.getFessConfig();
731         final String secure = fessConfig.getCookieSearchParameterSecure();
732         if (StringUtil.isBlank(secure)) {
733             final String forwardedProto = request.getHeader("X-Forwarded-Proto");
734             if ("https".equalsIgnoreCase(forwardedProto)) {
735                 return true;
736             }
737             return request.isSecure();
738         }
739         return Constants.TRUE.equalsIgnoreCase(secure);
740     }
741 
742     /**
743      * Interface for rewriting search request parameters.
744      *
745      * Implementations can modify search parameters before they are processed
746      * by the search engine, allowing for custom parameter transformation logic.
747      */
748     public interface SearchRequestParamsRewriter {
749         /**
750          * Rewrites the given search request parameters.
751          *
752          * @param params The original search request parameters
753          * @return Modified search request parameters
754          */
755         SearchRequestParams rewrite(SearchRequestParams params);
756     }
757 }