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.taglib;
17  
18  import static org.codelibs.core.stream.StreamUtil.stream;
19  
20  import java.io.File;
21  import java.math.RoundingMode;
22  import java.nio.file.Files;
23  import java.nio.file.Path;
24  import java.nio.file.Paths;
25  import java.text.DecimalFormat;
26  import java.text.NumberFormat;
27  import java.text.SimpleDateFormat;
28  import java.time.LocalDateTime;
29  import java.time.ZonedDateTime;
30  import java.time.format.DateTimeFormatter;
31  import java.util.ArrayList;
32  import java.util.Base64;
33  import java.util.Calendar;
34  import java.util.Date;
35  import java.util.Enumeration;
36  import java.util.List;
37  import java.util.Locale;
38  import java.util.Map;
39  import java.util.Objects;
40  import java.util.concurrent.ExecutionException;
41  import java.util.concurrent.TimeUnit;
42  import java.util.regex.Matcher;
43  import java.util.regex.Pattern;
44  import java.util.stream.Collectors;
45  
46  import org.apache.commons.lang3.time.DurationFormatUtils;
47  import org.apache.commons.text.StringEscapeUtils;
48  import org.apache.logging.log4j.LogManager;
49  import org.apache.logging.log4j.Logger;
50  import org.apache.pdfbox.util.DateConverter;
51  import org.codelibs.core.lang.StringUtil;
52  import org.codelibs.fess.Constants;
53  import org.codelibs.fess.app.web.base.FessAdminAction;
54  import org.codelibs.fess.app.web.base.login.FessLoginAssist;
55  import org.codelibs.fess.entity.FacetQueryView;
56  import org.codelibs.fess.helper.ViewHelper;
57  import org.codelibs.fess.util.ComponentUtil;
58  import org.lastaflute.di.util.LdiURLUtil;
59  import org.lastaflute.web.LastaWebKey;
60  import org.lastaflute.web.util.LaRequestUtil;
61  import org.lastaflute.web.util.LaResponseUtil;
62  import org.lastaflute.web.util.LaServletContextUtil;
63  import org.opensearch.common.joda.Joda;
64  
65  import com.google.common.cache.CacheBuilder;
66  import com.google.common.cache.CacheLoader;
67  import com.google.common.cache.LoadingCache;
68  
69  import jakarta.servlet.http.HttpServletRequest;
70  
71  /**
72   * Utility class providing static functions for Fess JSP/JSTL expressions and tag libraries.
73   * This class contains various helper methods for formatting, parsing, and manipulating data
74   * in Fess web templates, including date formatting, localization, file operations, and
75   * query parameter handling.
76   *
77   * @since 1.0
78   */
79  public class FessFunctions {
80      /** Logger instance for this class */
81      private static final Logger logger = LogManager.getLogger(FessFunctions.class);
82  
83      /** Prefix for geographic query parameters */
84      private static final String GEO_PREFIX = "geo.";
85  
86      /** Prefix for facet query parameters */
87      private static final String FACET_PREFIX = "facet.";
88  
89      /** Format identifier for PDF date parsing */
90      private static final String PDF_DATE = "pdf_date";
91  
92      /** Regular expression pattern for matching email addresses */
93      private static final Pattern EMAIL_ADDRESS_PATTERN =
94              Pattern.compile("[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}", Pattern.CASE_INSENSITIVE);
95  
96      /**
97       * Cache for storing resource file modification timestamps to enable cache busting.
98       * The cache expires after 10 minutes and has a maximum size of 1000 entries.
99       */
100     private static LoadingCache<String, Long> resourceHashCache =
101             CacheBuilder.newBuilder().maximumSize(1000).expireAfterWrite(10, TimeUnit.MINUTES).build(new CacheLoader<String, Long>() {
102                 @Override
103                 public Long load(final String key) throws Exception {
104                     try {
105                         final Path path = Paths.get(LaServletContextUtil.getServletContext().getRealPath(key));
106                         if (Files.isRegularFile(path)) {
107                             return Files.getLastModifiedTime(path).toMillis();
108                         }
109                     } catch (final Exception e) {
110                         logger.debug("Failed to access resource file: path={}", key, e);
111                     }
112                     return 0L;
113                 }
114             });
115 
116     /**
117      * Private constructor to prevent instantiation of this utility class.
118      * This class is intended to be used only through its static methods.
119      */
120     protected FessFunctions() {
121         // nothing
122     }
123 
124     /**
125      * Generates an HTML opening or closing tag with appropriate language attribute.
126      *
127      * @param isOpen true to generate opening HTML tag, false for closing tag
128      * @return HTML opening tag with language attribute or closing tag
129      */
130     public static String html(final boolean isOpen) {
131         if (isOpen) {
132             return "<html lang=\"" + LaRequestUtil.getOptionalRequest().map(req -> {
133                 if (req.getAttribute(LastaWebKey.USER_LOCALE_KEY) instanceof Locale locale) {
134                     return locale;
135                 }
136                 return Locale.ENGLISH;
137             }).orElse(Locale.ENGLISH).getLanguage() + "\">";
138         }
139         return "</html>";
140     }
141 
142     /**
143      * Checks if a label with the specified key exists in the current request's label map.
144      *
145      * @param value the label key to check
146      * @return true if the label exists, false otherwise
147      */
148     public static Boolean labelExists(final String value) {
149         return LaRequestUtil.getOptionalRequest().map(req -> {
150             @SuppressWarnings("unchecked")
151             final Map<String, String> labelValueMap = (Map<String, String>) req.getAttribute(Constants.LABEL_VALUE_MAP);
152             if (labelValueMap != null) {
153                 return labelValueMap.get(value) != null;
154             }
155             return false;
156         }).orElse(false);
157     }
158 
159     /**
160      * Retrieves the localized label value for the given key from the current request's label map.
161      *
162      * @param value the label key to retrieve
163      * @return the localized label value, or the key itself if not found
164      */
165     public static String label(final String value) {
166         return LaRequestUtil.getOptionalRequest().map(req -> {
167             @SuppressWarnings("unchecked")
168             final Map<String, String> labelValueMap = (Map<String, String>) req.getAttribute(Constants.LABEL_VALUE_MAP);
169             if (labelValueMap != null) {
170                 return labelValueMap.get(value);
171             }
172             return null;
173         }).orElse(value);
174     }
175 
176     /**
177      * Converts a Long timestamp to a Date object.
178      *
179      * @param value the timestamp in milliseconds
180      * @return Date object representing the timestamp, or null if value is null
181      */
182     public static Date date(final Long value) {
183         if (value == null) {
184             return null;
185         }
186         return new Date(value);
187     }
188 
189     /**
190      * Parses a date string using the default date format.
191      *
192      * @param value the date string to parse
193      * @return parsed Date object, or null if parsing fails
194      */
195     public static Date parseDate(final String value) {
196         return parseDate(value, Constants.DATE_OPTIONAL_TIME);
197     }
198 
199     /**
200      * Parses a date string using the specified format.
201      *
202      * @param value the date string to parse
203      * @param format the date format pattern or "pdf_date" for PDF date format
204      * @return parsed Date object, or null if parsing fails
205      */
206     public static Date parseDate(final String value, final String format) {
207         if (value == null) {
208             return null;
209         }
210 
211         try {
212             if (PDF_DATE.equals(format)) {
213                 final Calendar cal = DateConverter.toCalendar(value);
214                 return cal != null ? cal.getTime() : null;
215             }
216 
217             final long time = Joda.forPattern(format).parseMillis(value);
218             return new Date(time);
219         } catch (final Exception e) {
220             return null;
221         }
222     }
223 
224     /**
225      * Formats a Date object to ISO datetime string format in UTC timezone.
226      *
227      * @param date the date to format
228      * @return formatted date string, or empty string if date is null
229      */
230     public static String formatDate(final Date date) {
231         if (date == null) {
232             return StringUtil.EMPTY;
233         }
234         final SimpleDateFormat sdf = new SimpleDateFormat(Constants.ISO_DATETIME_FORMAT);
235         sdf.setTimeZone(Constants.TIMEZONE_UTC);
236         return sdf.format(date);
237     }
238 
239     /**
240      * Formats a LocalDateTime object to ISO datetime string format.
241      *
242      * @param date the LocalDateTime to format
243      * @return formatted date string, or empty string if date is null
244      */
245     public static String formatDate(final LocalDateTime date) {
246         if (date == null) {
247             return StringUtil.EMPTY;
248         }
249         return date.format(DateTimeFormatter.ofPattern(Constants.ISO_DATETIME_FORMAT, Locale.ROOT));
250     }
251 
252     /**
253      * Formats a ZonedDateTime object using the specified format pattern.
254      *
255      * @param date the ZonedDateTime to format
256      * @param format the date format pattern
257      * @return formatted date string, or empty string if date is null
258      */
259     public static String formatDate(final ZonedDateTime date, final String format) {
260         if (date == null) {
261             return StringUtil.EMPTY;
262         }
263         return date.format(DateTimeFormatter.ofPattern(format, Locale.ROOT));
264     }
265 
266     /**
267      * Formats a duration in milliseconds to a human-readable string.
268      *
269      * @param durationMillis the duration in milliseconds
270      * @return formatted duration string (e.g., "2 days 14:30:25.123" or "14:30:25.123")
271      */
272     public static String formatDuration(final long durationMillis) {
273         return DurationFormatUtils.formatDuration(durationMillis, "d 'days' HH:mm:ss.SSS").replace("0 days", StringUtil.EMPTY).trim();
274 
275     }
276 
277     /**
278      * Formats a number using the specified pattern and user's locale.
279      *
280      * @param value the number to format
281      * @param pattern the number format pattern
282      * @return formatted number string
283      */
284     public static String formatNumber(final long value, final String pattern) {
285         final DecimalFormat df = (DecimalFormat) NumberFormat.getNumberInstance(getUserLocale());
286         df.applyPattern(pattern);
287         return df.format(value);
288     }
289 
290     /**
291      * Retrieves the current user's locale from the request manager.
292      *
293      * @return the user's locale, or Locale.ROOT if not available
294      */
295     private static Locale getUserLocale() {
296         if (ComponentUtil.hasRequestManager()) {
297             final Locale locale = ComponentUtil.getRequestManager().getUserLocale();
298             if (locale != null) {
299                 return locale;
300             }
301         }
302         return Locale.ROOT;
303     }
304 
305     /**
306      * Formats a file size in bytes to a human-readable string with appropriate units.
307      *
308      * @param value the file size in bytes
309      * @return formatted file size string (e.g., "1.5M", "2.3G", "512K")
310      */
311     public static String formatFileSize(final long value) {
312         double target = value;
313         String unit = ""; // TODO l10n?
314         String format = "0.#";
315         if (value < 1024) {
316             format = "0";
317         } else if (value < 1024L * 1024L) {
318             target /= 1024;
319             unit = "K";
320         } else if (value < 1024L * 1024L * 1024L) {
321             target /= 1024;
322             target /= 1024;
323             unit = "M";
324         } else if (value < 1024L * 1024L * 1024L * 1024L) {
325             target /= 1024;
326             target /= 1024;
327             target /= 1024;
328             unit = "G";
329         } else {
330             target /= 1024;
331             target /= 1024;
332             target /= 1024;
333             target /= 1024;
334             unit = "T";
335         }
336         final DecimalFormat df = (DecimalFormat) NumberFormat.getNumberInstance(getUserLocale());
337         df.applyPattern(format);
338         df.setRoundingMode(RoundingMode.HALF_UP);
339         return df.format(target) + unit;
340     }
341 
342     /**
343      * Generates URL query parameters for pagination, excluding the specified query parameter.
344      *
345      * @param query the query parameter to exclude from paging
346      * @return URL-encoded query string for pagination
347      */
348     public static String pagingQuery(final String query) {
349         return LaRequestUtil.getOptionalRequest().map(req -> {
350             @SuppressWarnings("unchecked")
351             final List<String> pagingQueryList = (List<String>) req.getAttribute(Constants.PAGING_QUERY_LIST);
352             if (pagingQueryList != null) {
353                 final String prefix;
354                 if (query != null) {
355                     prefix = "ex_q=" + query.split(":")[0] + "%3A";
356                 } else {
357                     prefix = null;
358                 }
359                 return pagingQueryList.stream()
360                         .filter(s -> prefix == null || !s.startsWith(prefix))
361                         .collect(Collectors.joining("&", "&", StringUtil.EMPTY));
362             }
363             return null;
364         }).orElse(StringUtil.EMPTY);
365     }
366 
367     /**
368      * Generates URL query parameters for facet filtering.
369      *
370      * @return URL-encoded query string containing facet parameters
371      */
372     public static String facetQuery() {
373         return createQuery(Constants.FACET_QUERY, FACET_PREFIX);
374     }
375 
376     /**
377      * Generates URL query parameters for geographic filtering.
378      *
379      * @return URL-encoded query string containing geographic parameters
380      */
381     public static String geoQuery() {
382         return createQuery(Constants.GEO_QUERY, GEO_PREFIX);
383     }
384 
385     /**
386      * Generates hidden HTML form fields for facet filtering.
387      *
388      * @return HTML string containing hidden input fields for facet parameters
389      */
390     public static String facetForm() {
391         return createForm(Constants.FACET_FORM, FACET_PREFIX);
392     }
393 
394     /**
395      * Generates hidden HTML form fields for geographic filtering.
396      *
397      * @return HTML string containing hidden input fields for geographic parameters
398      */
399     public static String geoForm() {
400         return createForm(Constants.GEO_FORM, GEO_PREFIX);
401     }
402 
403     /**
404      * Retrieves the list of facet query view objects for display.
405      *
406      * @return list of FacetQueryView objects
407      */
408     public static List<FacetQueryView> facetQueryViewList() {
409         final ViewHelper viewHelper = ComponentUtil.getViewHelper();
410         return viewHelper.getFacetQueryViewList();
411     }
412 
413     /**
414      * Creates a URL query string from request parameters that start with the specified prefix.
415      *
416      * @param key the request attribute key to cache the result
417      * @param prefix the parameter name prefix to filter by
418      * @return URL-encoded query string
419      */
420     private static String createQuery(final String key, final String prefix) {
421         return LaRequestUtil.getOptionalRequest().map(request -> {
422             String query = (String) request.getAttribute(key);
423             if (query == null) {
424                 final StringBuilder buf = new StringBuilder(100);
425                 final Enumeration<String> names = request.getParameterNames();
426                 while (names.hasMoreElements()) {
427                     final String name = names.nextElement();
428                     if (name.startsWith(prefix)) {
429                         final String[] values = request.getParameterValues(name);
430                         if (values != null) {
431                             for (final String value : values) {
432                                 buf.append('&');
433                                 buf.append(LdiURLUtil.encode(name, Constants.UTF_8));
434                                 buf.append('=');
435                                 buf.append(LdiURLUtil.encode(value, Constants.UTF_8));
436                             }
437                         }
438                     }
439                 }
440                 query = buf.toString();
441                 request.setAttribute(key, query);
442             }
443             return query;
444         }).orElse(null);
445     }
446 
447     /**
448      * Creates HTML hidden form fields from request parameters that start with the specified prefix.
449      *
450      * @param key the request attribute key to cache the result
451      * @param prefix the parameter name prefix to filter by
452      * @return HTML string containing hidden input fields
453      */
454     private static String createForm(final String key, final String prefix) {
455         return LaRequestUtil.getOptionalRequest().map(request -> {
456             String query = (String) request.getAttribute(key);
457             if (query == null) {
458                 final StringBuilder buf = new StringBuilder(100);
459                 final Enumeration<String> names = request.getParameterNames();
460                 while (names.hasMoreElements()) {
461                     final String name = names.nextElement();
462                     if (name.startsWith(prefix)) {
463                         final String[] values = request.getParameterValues(name);
464                         if (values != null) {
465                             for (final String value : values) {
466                                 buf.append("<input type=\"hidden\" name=\"");
467                                 buf.append(StringEscapeUtils.escapeHtml4(name));
468                                 buf.append("\" value=\"");
469                                 buf.append(StringEscapeUtils.escapeHtml4(value));
470                                 buf.append("\"/>");
471                             }
472                         }
473                     }
474                 }
475                 query = buf.toString();
476                 request.setAttribute(key, query);
477             }
478             return query;
479         }).orElse(null);
480     }
481 
482     /**
483      * Encodes a string to URL-safe Base64 format.
484      *
485      * @param value the string to encode
486      * @return Base64 encoded string, or empty string if value is null
487      */
488     public static String base64(final String value) {
489         if (value == null) {
490             return StringUtil.EMPTY;
491         }
492         return Base64.getUrlEncoder().encodeToString(value.getBytes(Constants.CHARSET_UTF_8));
493     }
494 
495     /**
496      * Checks if a file exists at the specified path within the servlet context.
497      *
498      * @param path the file path relative to the servlet context
499      * @return true if the file exists, false otherwise
500      */
501     public static boolean fileExists(final String path) {
502         final File file = new File(LaServletContextUtil.getServletContext().getRealPath(path));
503         return file.exists();
504     }
505 
506     /**
507      * Generates a complete URL with context path and cache-busting timestamp.
508      *
509      * @param input the relative URL path starting with '/'
510      * @return complete URL with context path and optional timestamp parameter
511      * @throws IllegalArgumentException if input is null or doesn't start with '/'
512      */
513     public static String url(final String input) {
514         if (input == null) {
515             final String msg = "The argument 'input' should not be null.";
516             throw new IllegalArgumentException(msg);
517         }
518         if (!input.startsWith("/")) {
519             final String msg = "The argument 'input' should start with slash '/': " + input;
520             throw new IllegalArgumentException(msg);
521         }
522         final StringBuilder sb = new StringBuilder();
523         LaRequestUtil.getOptionalRequest().map(HttpServletRequest::getContextPath).filter(s -> s.length() > 1).ifPresent(s -> sb.append(s));
524         sb.append(input);
525         if (input.indexOf('?') == -1) {
526             try {
527                 final Long value = resourceHashCache.get(input);
528                 if (value.longValue() > 0) {
529                     sb.append("?t=").append(value.toString());
530                 }
531             } catch (final ExecutionException e) {
532                 logger.debug("Failed to access resource hash cache: path={}", input, e);
533             }
534         }
535         return LaResponseUtil.getResponse().encodeURL(sb.toString());
536     }
537 
538     /**
539      * Encodes a string for similar document hash processing.
540      *
541      * @param input the string to encode
542      * @return encoded string, or the original input if blank
543      */
544     public static String sdh(final String input) {
545         if (StringUtil.isBlank(input)) {
546             return input;
547         }
548         return ComponentUtil.getDocumentHelper().encodeSimilarDocHash(input);
549     }
550 
551     /**
552      * Joins array or list elements into a single space-separated string.
553      *
554      * @param input the input object (String[], List, or String)
555      * @return joined string with elements separated by spaces, or empty string if invalid input
556      */
557     public static String join(final Object input) {
558         String[] values = null;
559         if (input instanceof String[]) {
560             values = (String[]) input;
561         } else if (input instanceof List) {
562             values = ((List<?>) input).stream().filter(Objects::nonNull).map(Object::toString).toArray(n -> new String[n]);
563         } else if (input instanceof String) {
564             return input.toString();
565         }
566         if (values != null) {
567             return stream(values).get(stream -> stream.filter(StringUtil::isNotBlank).map(String::trim).collect(Collectors.joining(" ")));
568         }
569         return StringUtil.EMPTY;
570     }
571 
572     /**
573      * Escapes a string so it can be safely embedded inside a JavaScript string literal.
574      * Single quotes, double quotes, backslashes, and control characters are escaped.
575      *
576      * @param input the input string to escape
577      * @return JavaScript-safe escaped string, or empty string if input is null
578      */
579     public static String escapeJs(final String input) {
580         if (input == null) {
581             return StringUtil.EMPTY;
582         }
583         return StringEscapeUtils.escapeEcmaScript(input);
584     }
585 
586     /**
587      * Replaces all occurrences of a regular expression pattern in the input string.
588      *
589      * @param input the input object to process
590      * @param regex the regular expression pattern to match
591      * @param replacement the replacement string
592      * @return string with all matches replaced, or empty string if input is null
593      */
594     public static String replace(final Object input, final String regex, final String replacement) {
595         if (input == null) {
596             return StringUtil.EMPTY;
597         }
598         return input.toString().replaceAll(regex, replacement);
599     }
600 
601     /**
602      * Formats code content with syntax highlighting and line numbers.
603      *
604      * @param prefix the line number prefix pattern
605      * @param style the CSS class name for styling
606      * @param mimetype the MIME type of the content (currently unused)
607      * @param input the code content to format
608      * @return HTML formatted code with line numbers and styling
609      */
610     public static String formatCode(final String prefix, final String style, final String mimetype, final String input) {
611         if (input == null) {
612             return StringUtil.EMPTY;
613         }
614         final Pattern pattern = Pattern.compile("^" + prefix + "([0-9]+):(.*)$");
615         final String[] values = input.split("\n");
616         final List<String> list = new ArrayList<>(values.length);
617         int lineNum = 0;
618         for (final String line : values) {
619             final Matcher matcher = pattern.matcher(line);
620             if (matcher.matches()) {
621                 if (lineNum == 0) {
622                     lineNum = Integer.parseInt(matcher.group(1));
623                     list.clear();
624                 }
625                 list.add(matcher.group(2));
626             } else {
627                 list.add(line);
628             }
629         }
630         if (lineNum == 0 || list.isEmpty()) {
631             return "<pre class=\"" + style + "\">" + input + "</pre>";
632         }
633         int lastIndex = list.size();
634         if (list.get(list.size() - 1).endsWith("...")) {
635             lastIndex--;
636         }
637         if (lastIndex <= 0) {
638             lastIndex = 1;
639         }
640         final String content = list.subList(0, lastIndex).stream().collect(Collectors.joining("\n"));
641         if (StringUtil.isBlank(content)) {
642             return "<pre class=\"" + style + "\">" + input.replaceAll("L[0-9]+:", StringUtil.EMPTY).trim() + "</pre>";
643         }
644         return "<pre class=\"" + style + " linenums:" + lineNum + "\">" + content + "</pre>";
645     }
646 
647     /**
648      * Retrieves a localized message for the given key.
649      *
650      * @param key the message key
651      * @param defaultValue the default value to return if message not found
652      * @return localized message or default value
653      */
654     public static String getMessage(final String key, final String defaultValue) {
655         return ComponentUtil.getMessageManager().findMessage(getUserLocale(), key).orElse(defaultValue);
656     }
657 
658     /**
659      * Checks if the current user has the specified action role or administrative privileges.
660      *
661      * @param role the role to check (supports both view and edit variants)
662      * @return true if the user has the role or admin privileges, false otherwise
663      */
664     public static boolean hasActionRole(final String role) {
665         final String[] roles;
666         if (role.endsWith(FessAdminAction.VIEW)) {
667             roles = new String[] { role, role.substring(0, role.length() - FessAdminAction.VIEW.length()) };
668         } else {
669             roles = new String[] { role };
670         }
671         final FessLoginAssist loginAssist = ComponentUtil.getComponent(FessLoginAssist.class);
672         return loginAssist.getSavedUserBean()
673                 .map(user -> user.hasRoles(roles) || user.hasRoles(ComponentUtil.getFessConfig().getAuthenticationAdminRolesAsArray()))
674                 .orElse(false);
675     }
676 
677     /**
678      * Masks email addresses in the input string for privacy protection.
679      *
680      * @param value the string that may contain email addresses
681      * @return string with email addresses replaced by masked pattern
682      */
683     public static String maskEmail(final String value) {
684         if (value == null) {
685             return StringUtil.EMPTY;
686         }
687         return EMAIL_ADDRESS_PATTERN.matcher(value).replaceAll("******@****.***");
688     }
689 }