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.util;
17  
18  import static org.codelibs.core.stream.StreamUtil.split;
19  
20  import java.util.ArrayList;
21  import java.util.Arrays;
22  import java.util.HashMap;
23  import java.util.LinkedList;
24  import java.util.List;
25  import java.util.Map;
26  import java.util.regex.Pattern;
27  import java.util.stream.Collectors;
28  
29  import javax.xml.XMLConstants;
30  import javax.xml.parsers.SAXParser;
31  import javax.xml.parsers.SAXParserFactory;
32  
33  import org.apache.logging.log4j.LogManager;
34  import org.apache.logging.log4j.Logger;
35  import org.codelibs.core.lang.StringUtil;
36  import org.codelibs.fess.Constants;
37  import org.codelibs.fess.exception.GsaConfigException;
38  import org.codelibs.fess.opensearch.config.exentity.FileConfig;
39  import org.codelibs.fess.opensearch.config.exentity.LabelType;
40  import org.codelibs.fess.opensearch.config.exentity.WebConfig;
41  import org.dbflute.optional.OptionalEntity;
42  import org.xml.sax.Attributes;
43  import org.xml.sax.InputSource;
44  import org.xml.sax.SAXException;
45  import org.xml.sax.helpers.DefaultHandler;
46  
47  /**
48   * Parser for Google Search Appliance (GSA) configuration files.
49   * This SAX-based parser reads GSA XML configuration files and converts them into
50   * Fess configuration objects including web crawling configurations, file crawling
51   * configurations, and label types for access control.
52   *
53   * <p>The parser handles the following GSA configuration elements:
54   * <ul>
55   * <li>Collections with good/bad URL patterns</li>
56   * <li>Global parameters including start URLs and filtering rules</li>
57   * <li>User agent settings</li>
58   * <li>URL pattern matching with regular expressions and contains filters</li>
59   * </ul>
60   *
61   */
62  public class GsaConfigParser extends DefaultHandler {
63  
64      /** Logger instance for this class. */
65      private static final Logger logger = LogManager.getLogger(GsaConfigParser.class);
66  
67      /** Prefix for regular expression patterns. */
68      public static final String REGEXP = "regexp:";
69  
70      /** Prefix for case-sensitive regular expression patterns. */
71      public static final String REGEXP_CASE = "regexpCase:";
72  
73      /** Prefix for case-insensitive regular expression patterns. */
74      public static final String REGEXP_IGNORE_CASE = "regexpIgnoreCase:";
75  
76      /** Prefix for contains-based string matching patterns. */
77      public static final String CONTAINS = "contains:";
78  
79      /** XML element name for collections container. */
80      protected static final String COLLECTIONS = "collections";
81  
82      /** XML element name for individual collection. */
83      protected static final String COLLECTION = "collection";
84  
85      /** XML element name for global parameters container. */
86      protected static final String GLOBALPARAMS = "globalparams";
87  
88      /** XML element name for start URLs configuration. */
89      protected static final String START_URLS = "start_urls";
90  
91      /** XML element name for good (included) URLs configuration. */
92      protected static final String GOOD_URLS = "good_urls";
93  
94      /** XML element name for bad (excluded) URLs configuration. */
95      protected static final String BAD_URLS = "bad_urls";
96  
97      /** Array of supported web protocols for URL classification. */
98      protected String[] webProtocols = { "http:", "https:" };
99  
100     /** Array of supported file protocols for URL classification. */
101     protected String[] fileProtocols = { "file:", "smb:", "smb1:", "ftp:", "storage:" };
102 
103     /** Queue to track the current XML element hierarchy during parsing. */
104     protected LinkedList<String> tagQueue;
105 
106     /** List to store parsed label types for access control. */
107     protected List<LabelType> labelList;
108 
109     /** Current label type being processed during parsing. */
110     protected LabelType labelType;
111 
112     /** Map to store global configuration parameters. */
113     protected Map<String, String> globalParams = new HashMap<>();
114 
115     /** Generated web crawling configuration from parsed GSA config. */
116     protected WebConfig webConfig = null;
117 
118     /** Generated file crawling configuration from parsed GSA config. */
119     protected FileConfig fileConfig = null;
120 
121     /** Buffer to accumulate character data between XML tags. */
122     protected StringBuilder textBuf = new StringBuilder(1000);
123 
124     /** User agent string to be used for web crawling. */
125     protected String userAgent = "gsa-crawler";
126 
127     /**
128      * Default constructor for GsaConfigParser.
129      */
130     public GsaConfigParser() {
131         super();
132     }
133 
134     /**
135      * Parses a GSA configuration XML file from the given input source.
136      * This method configures a secure SAX parser and processes the XML content
137      * to extract configuration information for web and file crawling.
138      *
139      * @param is the input source containing the GSA configuration XML
140      * @throws GsaConfigException if parsing fails due to XML format issues or other errors
141      */
142     public void parse(final InputSource is) {
143         try {
144             final SAXParserFactory factory = SAXParserFactory.newInstance();
145             factory.setFeature(org.codelibs.fess.crawler.Constants.FEATURE_SECURE_PROCESSING, true);
146             factory.setFeature(org.codelibs.fess.crawler.Constants.FEATURE_EXTERNAL_GENERAL_ENTITIES, false);
147             factory.setFeature(org.codelibs.fess.crawler.Constants.FEATURE_EXTERNAL_PARAMETER_ENTITIES, false);
148             final SAXParser parser = factory.newSAXParser();
149             parser.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, StringUtil.EMPTY);
150             parser.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, StringUtil.EMPTY);
151             parser.parse(is, this);
152         } catch (final Exception e) {
153             throw new GsaConfigException("Failed to parse XML file.", e);
154         }
155     }
156 
157     /**
158      * SAX event handler called at the beginning of document parsing.
159      * Initializes internal data structures for processing the GSA configuration.
160      *
161      * @throws SAXException if a SAX error occurs during initialization
162      */
163     @Override
164     public void startDocument() throws SAXException {
165         tagQueue = new LinkedList<>();
166         labelList = new ArrayList<>();
167         labelType = null;
168     }
169 
170     /**
171      * SAX event handler called at the end of document parsing.
172      * Cleans up internal data structures used during parsing.
173      *
174      * @throws SAXException if a SAX error occurs during cleanup
175      */
176     @Override
177     public void endDocument() throws SAXException {
178         globalParams.clear();
179         tagQueue.clear();
180     }
181 
182     /**
183      * SAX event handler called when an XML start element is encountered.
184      * Processes collection definitions and tracks the element hierarchy.
185      *
186      * @param uri the namespace URI, or empty string if none
187      * @param localName the local name without prefix, or empty string if namespace processing is not performed
188      * @param qName the qualified name with prefix, or empty string if qualified names are not available
189      * @param attributes the attributes attached to the element
190      * @throws SAXException if a SAX error occurs or if the XML format is invalid
191      */
192     @Override
193     public void startElement(final String uri, final String localName, final String qName, final Attributes attributes)
194             throws SAXException {
195         if (logger.isDebugEnabled()) {
196             logger.debug("Start element: name={}", qName);
197         }
198         if (tagQueue.isEmpty() && !"eef".equalsIgnoreCase(qName)) {
199             throw new GsaConfigException("Invalid GSA configuration format. Root element must be 'eef', but found: " + qName);
200         }
201         if (COLLECTION.equalsIgnoreCase(qName) && COLLECTIONS.equalsIgnoreCase(tagQueue.peekLast())) {
202             final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
203             final String name = attributes.getValue("Name");
204             labelType = new LabelType();
205             labelType.setName(name);
206             labelType.setValue(name);
207             labelType.setPermissions(new String[] { "Rguest" });
208             labelType.setCreatedBy(Constants.SYSTEM_USER);
209             labelType.setCreatedTime(now);
210             labelType.setUpdatedBy(Constants.SYSTEM_USER);
211             labelType.setUpdatedTime(now);
212         }
213         tagQueue.offer(qName);
214     }
215 
216     /**
217      * SAX event handler called when an XML end element is encountered.
218      * Processes the accumulated text content and creates appropriate configuration objects
219      * based on the element type (good_urls, bad_urls, start_urls, etc.).
220      *
221      * @param uri the namespace URI, or empty string if none
222      * @param localName the local name without prefix, or empty string if namespace processing is not performed
223      * @param qName the qualified name with prefix, or empty string if qualified names are not available
224      * @throws SAXException if a SAX error occurs during processing
225      */
226     @Override
227     public void endElement(final String uri, final String localName, final String qName) throws SAXException {
228         if (logger.isDebugEnabled()) {
229             logger.debug("End element: name={}", qName);
230         }
231         if (GOOD_URLS.equalsIgnoreCase(qName)) {
232             if (labelType != null) {
233                 labelType.setIncludedPaths(parseFilterPaths(textBuf.toString(), true, true));
234             } else if (GLOBALPARAMS.equalsIgnoreCase(tagQueue.get(tagQueue.size() - 2))) {
235                 globalParams.put(GOOD_URLS, textBuf.toString());
236             }
237         } else if (BAD_URLS.equalsIgnoreCase(qName)) {
238             if (labelType != null) {
239                 labelType.setExcludedPaths(parseFilterPaths(textBuf.toString(), true, true));
240             } else if (GLOBALPARAMS.equalsIgnoreCase(tagQueue.get(tagQueue.size() - 2))) {
241                 globalParams.put(BAD_URLS, textBuf.toString());
242             }
243         } else if (START_URLS.equalsIgnoreCase(qName) && GLOBALPARAMS.equalsIgnoreCase(tagQueue.get(tagQueue.size() - 2))) {
244             globalParams.put(START_URLS, textBuf.toString());
245         } else if (labelType != null && COLLECTION.equalsIgnoreCase(qName)) {
246             labelList.add(labelType);
247             labelType = null;
248         } else if (GLOBALPARAMS.equalsIgnoreCase(qName)) {
249             final Object startUrls = globalParams.get(START_URLS);
250             if (startUrls != null) {
251                 final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
252                 final List<String> urlList = split(startUrls.toString(), "\n")
253                         .get(stream -> stream.map(String::trim).filter(StringUtil::isNotBlank).collect(Collectors.toList()));
254 
255                 final String webUrls = urlList.stream()
256                         .filter(s -> Arrays.stream(webProtocols).anyMatch(p -> s.startsWith(p)))
257                         .collect(Collectors.joining("\n"));
258                 if (StringUtil.isNotBlank(webUrls)) {
259                     webConfig = new WebConfig();
260                     webConfig.setName("Default");
261                     webConfig.setAvailable(true);
262                     webConfig.setBoost(1.0f);
263                     webConfig.setConfigParameter(StringUtil.EMPTY);
264                     webConfig.setIntervalTime(1000);
265                     webConfig.setNumOfThread(3);
266                     webConfig.setSortOrder(1);
267                     webConfig.setUrls(webUrls);
268                     webConfig.setIncludedUrls(parseFilterPaths(globalParams.get(GOOD_URLS), true, false));
269                     webConfig.setIncludedDocUrls(StringUtil.EMPTY);
270                     webConfig.setExcludedUrls(parseFilterPaths(globalParams.get(BAD_URLS), true, false));
271                     webConfig.setExcludedDocUrls(StringUtil.EMPTY);
272                     webConfig.setUserAgent(userAgent);
273                     webConfig.setPermissions(new String[] { "Rguest" });
274                     webConfig.setCreatedBy(Constants.SYSTEM_USER);
275                     webConfig.setCreatedTime(now);
276                     webConfig.setUpdatedBy(Constants.SYSTEM_USER);
277                     webConfig.setUpdatedTime(now);
278                 }
279 
280                 final String fileUrls = urlList.stream()
281                         .filter(s -> Arrays.stream(fileProtocols).anyMatch(p -> s.startsWith(p)))
282                         .collect(Collectors.joining("\n"));
283                 if (StringUtil.isNotBlank(fileUrls)) {
284                     fileConfig = new FileConfig();
285                     fileConfig.setName("Default");
286                     fileConfig.setAvailable(true);
287                     fileConfig.setBoost(1.0f);
288                     fileConfig.setConfigParameter(StringUtil.EMPTY);
289                     fileConfig.setIntervalTime(0);
290                     fileConfig.setNumOfThread(5);
291                     fileConfig.setSortOrder(2);
292                     fileConfig.setPaths(fileUrls);
293                     fileConfig.setIncludedPaths(parseFilterPaths(globalParams.get(GOOD_URLS), false, true));
294                     fileConfig.setIncludedDocPaths(StringUtil.EMPTY);
295                     fileConfig.setExcludedPaths(parseFilterPaths(globalParams.get(BAD_URLS), false, true));
296                     fileConfig.setExcludedDocPaths(StringUtil.EMPTY);
297                     fileConfig.setPermissions(new String[] { "Rguest" });
298                     fileConfig.setCreatedBy(Constants.SYSTEM_USER);
299                     fileConfig.setCreatedTime(now);
300                     fileConfig.setUpdatedBy(Constants.SYSTEM_USER);
301                     fileConfig.setUpdatedTime(now);
302                 }
303             }
304         } else if ("user_agent".equalsIgnoreCase(qName) && GLOBALPARAMS.equalsIgnoreCase(tagQueue.get(tagQueue.size() - 2))) {
305             userAgent = textBuf.toString().trim();
306         }
307         tagQueue.pollLast();
308         textBuf.setLength(0);
309     }
310 
311     /**
312      * SAX event handler called to process character data between XML elements.
313      * Accumulates text content in a buffer for later processing when the element ends.
314      *
315      * @param ch the characters from the XML document
316      * @param start the start position in the character array
317      * @param length the number of characters to use from the character array
318      * @throws SAXException if a SAX error occurs during character processing
319      */
320     @Override
321     public void characters(final char[] ch, final int start, final int length) throws SAXException {
322         final String text = new String(ch, start, length);
323         if (logger.isDebugEnabled()) {
324             logger.debug("Text: content={}", text);
325         }
326         textBuf.append(text);
327     }
328 
329     /**
330      * Parses and filters URL patterns from text based on protocol types.
331      * Processes each line of the input text, filtering URLs based on web and file protocol support.
332      *
333      * @param text the raw text containing URL patterns, one per line
334      * @param web true if web protocol URLs should be included
335      * @param file true if file protocol URLs should be included
336      * @return a newline-separated string of filtered URL patterns
337      */
338     protected String parseFilterPaths(final String text, final boolean web, final boolean file) {
339         return split(text, "\n")
340                 .get(stream -> stream.map(String::trim).filter(StringUtil::isNotBlank).map(this::getFilterPath).filter(s -> {
341                     if (StringUtil.isBlank(s)) {
342                         return false;
343                     }
344                     if (Arrays.stream(webProtocols).anyMatch(p -> s.startsWith(p))) {
345                         return web;
346                     }
347                     if (Arrays.stream(fileProtocols).anyMatch(p -> s.startsWith(p))) {
348                         return file;
349                     }
350                     return true;
351                 }).collect(Collectors.joining("\n")));
352     }
353 
354     /**
355      * Converts a GSA URL pattern into a regular expression pattern suitable for Fess.
356      * Handles various GSA pattern formats including regexp, contains, and URL-based patterns.
357      *
358      * @param s the input GSA pattern string
359      * @return a regular expression pattern string, or empty string for comments/invalid patterns
360      */
361     protected String getFilterPath(final String s) {
362         if (s.startsWith("#")) {
363             return StringUtil.EMPTY;
364         }
365         if (s.startsWith(CONTAINS)) {
366             final String v = s.substring(CONTAINS.length());
367             final StringBuilder buf = new StringBuilder(100);
368             return ".*" + appendFileterPath(buf, escape(v)) + ".*";
369         }
370         if (s.startsWith(REGEXP_IGNORE_CASE)) {
371             final String v = s.substring(REGEXP_IGNORE_CASE.length());
372             final StringBuilder buf = new StringBuilder(100);
373             buf.append("(?i)");
374             return appendFileterPath(buf, unescape(v));
375         }
376         if (s.startsWith(REGEXP_CASE)) {
377             final String v = s.substring(REGEXP_CASE.length());
378             final StringBuilder buf = new StringBuilder(100);
379             return appendFileterPath(buf, unescape(v));
380         }
381         if (s.startsWith(REGEXP)) {
382             final String v = s.substring(REGEXP.length());
383             final StringBuilder buf = new StringBuilder(100);
384             return appendFileterPath(buf, unescape(v));
385         }
386         if (Arrays.stream(webProtocols).anyMatch(p -> s.startsWith(p)) || Arrays.stream(fileProtocols).anyMatch(p -> s.startsWith(p))) {
387             return escape(s) + ".*";
388         }
389         final StringBuilder buf = new StringBuilder(100);
390         return appendFileterPath(buf, escape(s));
391     }
392 
393     /**
394      * Escapes special regex characters in a string to create a literal pattern.
395      * Handles anchor characters (^ and $) specially to preserve their regex meaning.
396      *
397      * @param s the string to escape
398      * @return an escaped regex pattern, or empty string for comments
399      */
400     protected String escape(final String s) {
401         if (s.startsWith("#")) {
402             return StringUtil.EMPTY;
403         }
404         if (s.startsWith("^") && s.endsWith("$")) {
405             return "^" + Pattern.quote(s.substring(1, s.length() - 1)) + "$";
406         }
407         if (s.startsWith("^")) {
408             return "^" + Pattern.quote(s.substring(1));
409         }
410         if (s.endsWith("$")) {
411             return Pattern.quote(s.substring(0, s.length() - 1)) + "$";
412         }
413         return Pattern.quote(s);
414     }
415 
416     /**
417      * Unescapes double backslashes in regex patterns.
418      * Converts escaped backslashes (\\) back to single backslashes (\).
419      *
420      * @param s the string to unescape
421      * @return the unescaped string
422      */
423     protected String unescape(final String s) {
424         return s.replace("\\\\", "\\");
425     }
426 
427     /**
428      * Appends a filter path pattern to a string buffer with appropriate wildcards.
429      * Handles various pattern formats including anchored patterns and quoted patterns.
430      *
431      * @param buf the string buffer to append to
432      * @param v the pattern value to append
433      * @return the complete pattern string from the buffer
434      */
435     protected String appendFileterPath(final StringBuilder buf, final String v) {
436         if (StringUtil.isBlank(v)) {
437             return StringUtil.EMPTY;
438         }
439 
440         if (v.startsWith("^")) {
441             buf.append(v);
442             if (!v.endsWith("$")) {
443                 buf.append(".*");
444             }
445         } else if (v.endsWith("$")) {
446             buf.append(".*");
447             buf.append(v);
448         } else if (v.endsWith("/\\E")) {
449             buf.append(".*");
450             buf.append(v);
451             buf.append(".*");
452         } else {
453             buf.append(v);
454         }
455         return buf.toString();
456     }
457 
458     /**
459      * Sets the array of web protocols to recognize for URL classification.
460      *
461      * @param webProtocols array of protocol prefixes (e.g., "http:", "https:")
462      */
463     public void setWebProtocols(final String[] webProtocols) {
464         this.webProtocols = webProtocols;
465     }
466 
467     /**
468      * Sets the array of file protocols to recognize for URL classification.
469      *
470      * @param fileProtocols array of protocol prefixes (e.g., "file:", "smb:", "ftp:")
471      */
472     public void setFileProtocols(final String[] fileProtocols) {
473         this.fileProtocols = fileProtocols;
474     }
475 
476     /**
477      * Returns a string representation of this parser's current state.
478      * Includes information about parsed label types and configuration objects.
479      *
480      * @return a string representation of the parser state
481      */
482     @Override
483     public String toString() {
484         return "GsaConfigParser [labelList=" + labelList + ", webConfig=" + webConfig + ", fileConfig=" + fileConfig + "]";
485     }
486 
487     /**
488      * Gets the web crawling configuration generated from the parsed GSA config.
489      *
490      * @return an optional containing the web configuration, or empty if no web URLs were found
491      */
492     public OptionalEntity<WebConfig> getWebConfig() {
493         return OptionalUtil.ofNullable(webConfig);
494     }
495 
496     /**
497      * Gets the file crawling configuration generated from the parsed GSA config.
498      *
499      * @return an optional containing the file configuration, or empty if no file URLs were found
500      */
501     public OptionalEntity<FileConfig> getFileConfig() {
502         return OptionalUtil.ofNullable(fileConfig);
503     }
504 
505     /**
506      * Gets all label types (collections) parsed from the GSA configuration.
507      * Each label type represents a collection with its own URL filtering rules.
508      *
509      * @return an array of label types representing the parsed collections
510      */
511     public LabelType[] getLabelTypes() {
512         return labelList.toArray(new LabelType[labelList.size()]);
513     }
514 
515 }