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.UnsupportedEncodingException;
19  import java.net.URLEncoder;
20  import java.util.ArrayList;
21  import java.util.Base64;
22  import java.util.Collections;
23  import java.util.Date;
24  import java.util.HashMap;
25  import java.util.LinkedHashMap;
26  import java.util.List;
27  import java.util.Map;
28  import java.util.stream.Collectors;
29  
30  import org.apache.logging.log4j.LogManager;
31  import org.apache.logging.log4j.Logger;
32  import org.codelibs.core.lang.StringUtil;
33  import org.codelibs.core.security.MessageDigestUtil;
34  import org.codelibs.fess.Constants;
35  import org.codelibs.fess.app.service.CrawlingInfoService;
36  import org.codelibs.fess.exception.FessSystemException;
37  import org.codelibs.fess.mylasta.direction.FessConfig;
38  import org.codelibs.fess.opensearch.client.SearchEngineClient;
39  import org.codelibs.fess.opensearch.config.exentity.CrawlingConfig;
40  import org.codelibs.fess.opensearch.config.exentity.CrawlingInfo;
41  import org.codelibs.fess.opensearch.config.exentity.CrawlingInfoParam;
42  import org.codelibs.fess.util.ComponentUtil;
43  import org.opensearch.index.query.QueryBuilders;
44  import org.opensearch.search.aggregations.AggregationBuilders;
45  import org.opensearch.search.aggregations.BucketOrder;
46  import org.opensearch.search.aggregations.bucket.terms.Terms;
47  import org.opensearch.search.aggregations.bucket.terms.Terms.Bucket;
48  import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder;
49  
50  /**
51   * Helper class for managing crawling information and statistics.
52   * Provides functionality to track crawling sessions, manage document expiration,
53   * and handle crawling information storage and retrieval.
54   */
55  public class CrawlingInfoHelper {
56  
57      /**
58       * Creates a new instance of CrawlingInfoHelper.
59       */
60      public CrawlingInfoHelper() {
61          // Default constructor
62      }
63  
64      private static final Logger logger = LogManager.getLogger(CrawlingInfoHelper.class);
65  
66      /**
67       * Key used for facet count aggregations.
68       */
69      public static final String FACET_COUNT_KEY = "count";
70  
71      /**
72       * Map containing crawling information parameters.
73       */
74      protected Map<String, String> infoMap;
75  
76      /**
77       * Document expiration time in milliseconds.
78       */
79      protected Long documentExpires;
80  
81      /**
82       * Maximum number of session IDs to include in lists.
83       */
84      protected int maxSessionIdsInList;
85  
86      /**
87       * Retrieves the CrawlingInfoService component instance.
88       *
89       * @return the CrawlingInfoService component for managing crawling information
90       */
91      protected CrawlingInfoService getCrawlingInfoService() {
92          return ComponentUtil.getComponent(CrawlingInfoService.class);
93      }
94  
95      /**
96       * Extracts the canonical session ID by removing any suffix after the first hyphen.
97       * If the session ID contains a hyphen, returns the portion before the first hyphen.
98       * Otherwise, returns the original session ID.
99       *
100      * @param sessionId the session ID to process
101      * @return the canonical session ID (portion before first hyphen, or original if no hyphen)
102      */
103     public String getCanonicalSessionId(final String sessionId) {
104         final int idx = sessionId.indexOf('-');
105         if (idx >= 0) {
106             return sessionId.substring(0, idx);
107         }
108         return sessionId;
109     }
110 
111     /**
112      * Stores crawling information and parameters for the specified session.
113      * Creates a new crawling info record if none exists or if create flag is true.
114      * Also stores any accumulated information parameters and clears the info map.
115      *
116      * @param sessionId the session ID for the crawling information
117      * @param create if true, creates a new crawling info regardless of existing records
118      * @throws FessSystemException if unable to store the crawling session
119      */
120     public synchronized void store(final String sessionId, final boolean create) {
121         CrawlingInfo crawlingInfo = create ? null : getCrawlingInfoService().getLast(sessionId);
122         if (crawlingInfo == null) {
123             crawlingInfo = new CrawlingInfo(sessionId);
124             try {
125                 getCrawlingInfoService().store(crawlingInfo);
126             } catch (final Exception e) {
127                 throw new FessSystemException("No crawling session.", e);
128             }
129         }
130 
131         if (infoMap != null) {
132             final List<CrawlingInfoParam> crawlingInfoParamList = new ArrayList<>();
133             for (final Map.Entry<String, String> entry : infoMap.entrySet()) {
134                 final CrawlingInfoParam crawlingInfoParam = new CrawlingInfoParam();
135                 crawlingInfoParam.setCrawlingInfoId(crawlingInfo.getId());
136                 crawlingInfoParam.setKey(entry.getKey());
137                 crawlingInfoParam.setValue(entry.getValue());
138                 crawlingInfoParamList.add(crawlingInfoParam);
139             }
140             getCrawlingInfoService().storeInfo(crawlingInfoParamList);
141         }
142 
143         infoMap = null;
144     }
145 
146     /**
147      * Adds a key-value pair to the information map.
148      * Initializes the info map as a synchronized LinkedHashMap if it doesn't exist.
149      *
150      * @param key the parameter key to store
151      * @param value the parameter value to store
152      */
153     public synchronized void putToInfoMap(final String key, final String value) {
154         if (infoMap == null) {
155             infoMap = Collections.synchronizedMap(new LinkedHashMap<>());
156         }
157         logger.debug("infoMap: {}={} => {}", key, value, infoMap);
158         infoMap.put(key, value);
159     }
160 
161     /**
162      * Updates crawling information parameters for the specified session.
163      * Sets the name and expiration time based on the provided parameters.
164      *
165      * @param sessionId the session ID to update
166      * @param name the name to set for the crawling session (uses system name if blank)
167      * @param dayForCleanup number of days until cleanup (sets expiration if >= 0)
168      * @throws FessSystemException if unable to store the updated crawling session
169      */
170     public void updateParams(final String sessionId, final String name, final int dayForCleanup) {
171         final CrawlingInfo crawlingInfo = getCrawlingInfoService().getLast(sessionId);
172         if (crawlingInfo == null) {
173             logger.warn("No crawling session: {}", sessionId);
174             return;
175         }
176         if (StringUtil.isNotBlank(name)) {
177             crawlingInfo.setName(name);
178         } else {
179             crawlingInfo.setName(Constants.CRAWLING_INFO_SYSTEM_NAME);
180         }
181         if (dayForCleanup >= 0) {
182             final long expires = getExpiredTime(dayForCleanup);
183             crawlingInfo.setExpiredTime(expires);
184             documentExpires = expires;
185         }
186         try {
187             getCrawlingInfoService().store(crawlingInfo);
188         } catch (final Exception e) {
189             throw new FessSystemException("No crawling session.", e);
190         }
191 
192     }
193 
194     /**
195      * Calculates the document expiration date based on crawling configuration.
196      * If the config has a timeToLive value, calculates expiration from current time.
197      * Otherwise, returns the stored document expiration time.
198      *
199      * @param config the crawling configuration containing time-to-live settings
200      * @return the document expiration date, or null if no expiration is set
201      */
202     public Date getDocumentExpires(final CrawlingConfig config) {
203         if (config != null) {
204             final Integer timeToLive = config.getTimeToLive();
205             if (timeToLive != null) {
206                 // timeToLive minutes
207                 final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
208                 return new Date(now + timeToLive.longValue() * 1000 * 60);
209             }
210         }
211         return documentExpires != null ? new Date(documentExpires) : null;
212     }
213 
214     /**
215      * Calculates the expiration time in milliseconds from the current time.
216      *
217      * @param days the number of days from now when the item should expire
218      * @return the expiration time in milliseconds since epoch
219      */
220     protected long getExpiredTime(final int days) {
221         final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
222         return now + days * Constants.ONE_DAY_IN_MILLIS;
223     }
224 
225     /**
226      * Retrieves all crawling information parameters for the specified session as a map.
227      *
228      * @param sessionId the session ID to retrieve parameters for
229      * @return a map containing all key-value parameter pairs for the session
230      */
231     public Map<String, String> getInfoMap(final String sessionId) {
232         final List<CrawlingInfoParam> crawlingInfoParamList = getCrawlingInfoService().getLastCrawlingInfoParamList(sessionId);
233         final Map<String, String> map = new HashMap<>();
234         for (final CrawlingInfoParam crawlingInfoParam : crawlingInfoParamList) {
235             map.put(crawlingInfoParam.getKey(), crawlingInfoParam.getValue());
236         }
237         return map;
238     }
239 
240     /**
241      * Generates a unique document ID from the provided data map.
242      * Constructs an ID string from URL, roles, and virtual hosts, then generates a hash.
243      *
244      * @param dataMap the document data map containing URL, roles, and virtual host information
245      * @return a unique hashed ID string for the document
246      */
247     public String generateId(final Map<String, Object> dataMap) {
248         final FessConfig fessConfig = ComponentUtil.getFessConfig();
249         final String url = (String) dataMap.get(fessConfig.getIndexFieldUrl());
250         final StringBuilder buf = new StringBuilder(1000);
251 
252         @SuppressWarnings("unchecked")
253         final List<String> roleTypeList = (List<String>) dataMap.get(fessConfig.getIndexFieldRole());
254         buf.append(url);
255         if (roleTypeList != null && !roleTypeList.isEmpty()) {
256             buf.append(";r=");
257             buf.append(roleTypeList.stream().sorted().collect(Collectors.joining(",")));
258         }
259 
260         @SuppressWarnings("unchecked")
261         final List<String> virtualHostList = (List<String>) dataMap.get(fessConfig.getIndexFieldVirtualHost());
262         if (virtualHostList != null && !virtualHostList.isEmpty()) {
263             buf.append(";v=");
264             buf.append(virtualHostList.stream().sorted().collect(Collectors.joining(",")));
265         }
266 
267         final String urlId = buf.toString().trim();
268         return generateId(urlId);
269     }
270 
271     /**
272      * Retrieves a list of session IDs with their document counts from the search engine.
273      * Uses aggregation to get session segments and their corresponding document counts.
274      *
275      * @param searchEngineClient the search engine client to perform the query
276      * @return a list of maps containing session IDs and their document counts
277      */
278     public List<Map<String, String>> getSessionIdList(final SearchEngineClient searchEngineClient) {
279         final FessConfig fessConfig = ComponentUtil.getFessConfig();
280         return searchEngineClient.search(fessConfig.getIndexDocumentSearchIndex(), queryRequestBuilder -> {
281             queryRequestBuilder.setQuery(QueryBuilders.matchAllQuery());
282             final TermsAggregationBuilder termsBuilder = AggregationBuilders.terms(fessConfig.getIndexFieldSegment())
283                     .field(fessConfig.getIndexFieldSegment())
284                     .size(maxSessionIdsInList)
285                     .order(BucketOrder.key(false));
286             queryRequestBuilder.addAggregation(termsBuilder);
287             queryRequestBuilder.setPreference(Constants.SEARCH_PREFERENCE_LOCAL);
288             return true;
289         }, (queryRequestBuilder, execTime, searchResponse) -> {
290             final List<Map<String, String>> sessionIdList = new ArrayList<>();
291             searchResponse.ifPresent(response -> {
292                 final Terms terms = response.getAggregations().get(fessConfig.getIndexFieldSegment());
293                 for (final Bucket bucket : terms.getBuckets()) {
294                     final Map<String, String> map = new HashMap<>(2);
295                     map.put(fessConfig.getIndexFieldSegment(), bucket.getKey().toString());
296                     map.put(FACET_COUNT_KEY, Long.toString(bucket.getDocCount()));
297                     sessionIdList.add(map);
298                 }
299             });
300             return sessionIdList;
301         });
302     }
303 
304     /**
305      * Generates a hashed ID from the provided URL ID string.
306      * Encodes special characters using URL encoding or Base64 encoding as needed,
307      * then applies a message digest algorithm to create a unique hash.
308      *
309      * @param urlId the URL ID string to generate a hash for
310      * @return a hashed ID string generated from the input URL ID
311      */
312     protected String generateId(final String urlId) {
313         final StringBuilder encodedBuf = new StringBuilder(urlId.length() + 100);
314         for (int i = 0; i < urlId.length(); i++) {
315             final char c = urlId.charAt(i);
316             if (c >= 'a' && c <= 'z' //
317                     || c >= 'A' && c <= 'Z' //
318                     || c >= '0' && c <= '9' //
319                     || c == '.' //
320                     || c == '-' //
321                     || c == '*' //
322                     || c == '_' //
323                     || c == ':' //
324                     || c == '+' //
325                     || c == '%' //
326                     || c == '=' //
327                     || c == '&' //
328                     || c == '?' //
329                     || c == '#' //
330                     || c == '[' //
331                     || c == ']' //
332                     || c == '@' //
333                     || c == '~' //
334                     || c == '!' //
335                     || c == '$' //
336                     || c == '\'' //
337                     || c == '(' //
338                     || c == ')' //
339                     || c == ',' //
340                     || c == ';' //
341             ) {
342                 encodedBuf.append(c);
343             } else {
344                 try {
345                     final String target = String.valueOf(c);
346                     final String converted = URLEncoder.encode(target, Constants.UTF_8);
347                     if (target.equals(converted)) {
348                         encodedBuf.append(Base64.getUrlEncoder().encodeToString(target.getBytes(Constants.CHARSET_UTF_8)));
349                     } else {
350                         encodedBuf.append(converted);
351                     }
352                 } catch (final UnsupportedEncodingException e) {
353                     // NOP
354                 }
355             }
356         }
357 
358         final String id = encodedBuf.toString();
359         return MessageDigestUtil.digest(ComponentUtil.getFessConfig().getIndexIdDigestAlgorithm(), id);
360     }
361 
362     /**
363      * Sets the maximum number of session IDs to include in session ID lists.
364      * This controls the size limit for aggregation results when retrieving session lists.
365      *
366      * @param maxSessionIdsInList the maximum number of session IDs to include in lists
367      */
368     public void setMaxSessionIdsInList(final int maxSessionIdsInList) {
369         this.maxSessionIdsInList = maxSessionIdsInList;
370     }
371 }