1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
45
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
59
60 public IndexExportJob() {
61
62 }
63
64
65
66
67
68
69
70 public IndexExportJob query(final QueryBuilder queryBuilder) {
71 this.queryBuilder = queryBuilder;
72 return this;
73 }
74
75
76
77
78
79
80
81 public IndexExportJob format(final String format) {
82 this.formatter = createFormatter(format);
83 return this;
84 }
85
86
87
88
89
90
91
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
109
110
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
164
165
166
167
168
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
207
208
209
210
211
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 }