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.job;
17  
18  import java.io.IOException;
19  import java.io.OutputStream;
20  import java.net.URI;
21  import java.nio.charset.StandardCharsets;
22  import java.nio.file.Files;
23  import java.nio.file.LinkOption;
24  import java.nio.file.Path;
25  import java.nio.file.Paths;
26  import java.nio.file.StandardOpenOption;
27  import java.security.MessageDigest;
28  import java.security.NoSuchAlgorithmException;
29  import java.util.Arrays;
30  import java.util.Map;
31  import java.util.Set;
32  import java.util.concurrent.atomic.AtomicLong;
33  import java.util.stream.Collectors;
34  
35  import org.apache.logging.log4j.LogManager;
36  import org.apache.logging.log4j.Logger;
37  import org.codelibs.fess.mylasta.direction.FessConfig;
38  import org.codelibs.fess.opensearch.client.SearchEngineClient;
39  import org.codelibs.fess.util.ComponentUtil;
40  import org.opensearch.index.query.QueryBuilder;
41  import org.opensearch.index.query.QueryBuilders;
42  
43  /**
44   * Job for exporting indexed search documents to the filesystem.
45   * Each document is exported as a single file with URL structure mapped to directory structure.
46   */
47  public class IndexExportJob {
48  
49      private static final Logger logger = LogManager.getLogger(IndexExportJob.class);
50  
51      private static final int MAX_PATH_COMPONENT_LENGTH = 200;
52  
53      private QueryBuilder queryBuilder;
54  
55      private IndexExportFormatter formatter;
56  
57      /**
58       * Creates a new IndexExportJob instance.
59       */
60      public IndexExportJob() {
61          // default constructor
62      }
63  
64      /**
65       * Sets the query to filter which documents to export.
66       *
67       * @param queryBuilder the query to use for filtering documents
68       * @return this instance for method chaining
69       */
70      public IndexExportJob query(final QueryBuilder queryBuilder) {
71          this.queryBuilder = queryBuilder;
72          return this;
73      }
74  
75      /**
76       * Sets the export format.
77       *
78       * @param format the format name (e.g. "html", "json")
79       * @return this instance for method chaining
80       */
81      public IndexExportJob format(final String format) {
82          this.formatter = createFormatter(format);
83          return this;
84      }
85  
86      /**
87       * Creates a formatter for the given format name.
88       *
89       * @param format the format name
90       * @return the formatter instance
91       * @throws IllegalArgumentException if the format is null, empty, or not supported
92       */
93      protected IndexExportFormatter createFormatter(final String format) {
94          if (format == null || format.trim().isEmpty()) {
95              throw new IllegalArgumentException("Export format must not be null or empty");
96          }
97          switch (format.trim().toLowerCase()) {
98          case "html":
99              return new HtmlIndexExportFormatter();
100         case "json":
101             return new JsonIndexExportFormatter();
102         default:
103             throw new IllegalArgumentException("Unsupported export format: " + format);
104         }
105     }
106 
107     /**
108      * Executes the export job, writing each matching document as a file.
109      *
110      * @return a string containing the execution result or error messages
111      */
112     public String execute() {
113         final SearchEngineClient searchEngineClient = ComponentUtil.getSearchEngineClient();
114         final FessConfig fessConfig = ComponentUtil.getFessConfig();
115 
116         final StringBuilder resultBuf = new StringBuilder();
117 
118         final String exportPath = fessConfig.getIndexExportPath();
119         final Set<String> excludeFields = Arrays.stream(fessConfig.getIndexExportExcludeFields().split(","))
120                 .map(String::trim)
121                 .filter(s -> !s.isEmpty())
122                 .collect(Collectors.toSet());
123         final int scrollSize = fessConfig.getIndexExportScrollSizeAsInteger();
124 
125         final IndexExportFormatter resolvedFormatter =
126                 this.formatter != null ? this.formatter : createFormatter(fessConfig.getIndexExportFormat());
127 
128         final QueryBuilder query = queryBuilder != null ? queryBuilder : QueryBuilders.matchAllQuery();
129 
130         if (logger.isInfoEnabled()) {
131             logger.info("[EXPORT] Starting index export: path={}, scrollSize={}, excludeFields={}, query={}", exportPath, scrollSize,
132                     excludeFields, query);
133         }
134 
135         final long startTime = System.currentTimeMillis();
136 
137         try {
138             final AtomicLong processedCount = new AtomicLong(0);
139             final long count = searchEngineClient.scrollSearch(fessConfig.getIndexDocumentSearchIndex(), requestBuilder -> {
140                 requestBuilder.setQuery(query).setSize(scrollSize);
141                 return true;
142             }, source -> {
143                 exportDocument(source, exportPath, excludeFields, resolvedFormatter);
144                 final long currentCount = processedCount.incrementAndGet();
145                 if (logger.isDebugEnabled() && currentCount % scrollSize == 0) {
146                     logger.debug("[EXPORT] Processing: count={}", currentCount);
147                 }
148                 return true;
149             });
150             resultBuf.append("Exported ").append(count).append(" documents.");
151             if (logger.isInfoEnabled()) {
152                 logger.info("[EXPORT] Completed: exportedCount={}, elapsedTime={}ms", count, System.currentTimeMillis() - startTime);
153             }
154         } catch (final Exception e) {
155             logger.warn("Failed to export documents.", e);
156             resultBuf.append(e.getMessage()).append("\n");
157         }
158 
159         return resultBuf.toString();
160     }
161 
162     /**
163      * Exports a single document as a file.
164      *
165      * @param source the document source map
166      * @param exportPath the base export directory path
167      * @param excludeFields the set of field names to exclude from output
168      * @param formatter the formatter to use for output
169      */
170     protected void exportDocument(final Map<String, Object> source, final String exportPath, final Set<String> excludeFields,
171             final IndexExportFormatter formatter) {
172         final Object urlObj = source.get("url");
173         if (urlObj == null) {
174             logger.debug("Skipping document without url field.");
175             return;
176         }
177 
178         final String url = urlObj.toString();
179         final Path filePath = buildFilePath(exportPath, url, formatter);
180         if (logger.isDebugEnabled()) {
181             logger.debug("[EXPORT] Exporting document: url={}, path={}", url, filePath);
182         }
183         final String content = formatter.format(source, excludeFields);
184 
185         try {
186             final Path basePath = Paths.get(exportPath);
187             Files.createDirectories(basePath);
188             final Path realBase = basePath.toRealPath();
189             Files.createDirectories(filePath.getParent());
190             final Path realParent = filePath.getParent().toRealPath();
191             if (!realParent.startsWith(realBase)) {
192                 logger.warn("Symlink traversal detected: url={}, realParent={}, realBase={}", url, realParent, realBase);
193                 return;
194             }
195             final byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
196             try (OutputStream out = Files.newOutputStream(filePath, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING,
197                     LinkOption.NOFOLLOW_LINKS)) {
198                 out.write(bytes);
199             }
200         } catch (final IOException e) {
201             logger.warn("Failed to export document: url={}", url, e);
202         }
203     }
204 
205     /**
206      * Builds a filesystem path from a document URL.
207      *
208      * @param exportPath the base export directory path
209      * @param url the document URL
210      * @param formatter the formatter to determine file extensions
211      * @return the target file path
212      */
213     protected Path buildFilePath(final String exportPath, final String url, final IndexExportFormatter formatter) {
214         try {
215             final URI uri = new URI(url);
216             String host = uri.getHost();
217             String path = uri.getPath();
218 
219             if (host == null || host.isEmpty()) {
220                 host = "_local";
221             }
222 
223             if (path == null || path.isEmpty()) {
224                 path = "/" + formatter.getIndexFileName();
225             } else if (path.endsWith("/")) {
226                 path = path + formatter.getIndexFileName();
227             } else if (!path.contains(".") || path.lastIndexOf('.') < path.lastIndexOf('/')) {
228                 path = path + formatter.getFileExtension();
229             }
230 
231             if (path.startsWith("/")) {
232                 path = path.substring(1);
233             }
234 
235             final String[] components = (host + "/" + path).split("/");
236             final StringBuilder sanitized = new StringBuilder();
237             for (int i = 0; i < components.length; i++) {
238                 String component = components[i].replaceAll("[<>:\"|?*\\\\]", "_");
239                 if (".".equals(component) || "..".equals(component)) {
240                     continue;
241                 }
242                 if (component.length() > MAX_PATH_COMPONENT_LENGTH) {
243                     component = component.substring(0, MAX_PATH_COMPONENT_LENGTH);
244                 }
245                 if (sanitized.length() > 0) {
246                     sanitized.append('/');
247                 }
248                 sanitized.append(component);
249             }
250 
251             final Path resolved = Paths.get(exportPath, sanitized.toString()).normalize();
252             final Path baseDir = Paths.get(exportPath).normalize();
253             if (!resolved.startsWith(baseDir)) {
254                 logger.warn("Path traversal detected: url={}, resolved={}", url, resolved);
255                 return Paths.get(exportPath, "_invalid", hashString(url) + formatter.getFileExtension());
256             }
257             return resolved;
258         } catch (final Exception e) {
259             logger.debug("Failed to parse URL: {}", url, e);
260             return Paths.get(exportPath, "_invalid", hashString(url) + formatter.getFileExtension());
261         }
262     }
263 
264     private String hashString(final String input) {
265         try {
266             final MessageDigest md = MessageDigest.getInstance("SHA-256");
267             final byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
268             final StringBuilder sb = new StringBuilder();
269             for (final byte b : hash) {
270                 sb.append(String.format("%02x", b));
271             }
272             return sb.toString();
273         } catch (final NoSuchAlgorithmException e) {
274             return String.valueOf(input.hashCode());
275         }
276     }
277 }