1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 package org.codelibs.fess.thumbnail.impl;
17
18 import java.io.File;
19 import java.io.IOException;
20 import java.nio.charset.Charset;
21 import java.nio.file.Files;
22 import java.nio.file.Path;
23 import java.util.ArrayList;
24 import java.util.List;
25 import java.util.Timer;
26 import java.util.TimerTask;
27 import java.util.concurrent.TimeUnit;
28 import java.util.concurrent.atomic.AtomicBoolean;
29
30 import org.apache.logging.log4j.LogManager;
31 import org.apache.logging.log4j.Logger;
32 import org.codelibs.core.concurrent.CommonPoolUtil;
33 import org.codelibs.core.io.CloseableUtil;
34 import org.codelibs.core.io.CopyUtil;
35 import org.codelibs.core.lang.StringUtil;
36 import org.codelibs.fess.mylasta.direction.FessConfig;
37 import org.codelibs.fess.util.ComponentUtil;
38 import org.codelibs.fess.util.InputStreamThread;
39
40 import jakarta.annotation.PostConstruct;
41
42
43
44
45
46 public class CommandGenerator extends BaseThumbnailGenerator {
47 private static final Logger logger = LogManager.getLogger(CommandGenerator.class);
48
49
50 protected List<String> commandList;
51
52
53 protected long commandTimeout = 30 * 1000L;
54
55
56 protected long commandDestroyTimeout = 5 * 1000L;
57
58
59 protected File baseDir;
60
61
62 private Timer destoryTimer;
63
64
65
66
67 public CommandGenerator() {
68 super();
69 }
70
71
72
73
74 @PostConstruct
75 public void init() {
76 if (logger.isDebugEnabled()) {
77 logger.debug("Initializing {}", this.getClass().getSimpleName());
78 }
79 if (baseDir == null) {
80 baseDir = new File(System.getProperty("java.io.tmpdir"));
81 }
82 destoryTimer = new Timer("CommandGeneratorDestoryTimer-" + ComponentUtil.getSystemHelper().getCurrentTimeAsLong(), true);
83 updateProperties();
84 }
85
86
87
88
89 protected void updateProperties() {
90 final FessConfig fessConfig = ComponentUtil.getFessConfig();
91 final String commandTimeoutStr = fessConfig.getSystemProperty("thumbnail.command.timeout");
92 if (commandTimeoutStr != null) {
93 commandTimeout = Long.parseLong(commandTimeoutStr);
94 }
95 final String commandDestroyTimeoutStr = fessConfig.getSystemProperty("thumbnail.command.destroy.timeout");
96 if (commandDestroyTimeoutStr != null) {
97 commandDestroyTimeout = Long.parseLong(commandDestroyTimeoutStr);
98 }
99 }
100
101
102
103
104 @Override
105 public void destroy() {
106 destoryTimer.cancel();
107 destoryTimer = null;
108 }
109
110
111
112
113
114
115
116 @Override
117 public boolean generate(final String thumbnailId, final File outputFile) {
118 if (logger.isDebugEnabled()) {
119 logger.debug("Generate Thumbnail: {}", thumbnailId);
120 }
121
122 if (outputFile.exists()) {
123 if (logger.isDebugEnabled()) {
124 logger.debug("The thumbnail file exists: {}", outputFile.getAbsolutePath());
125 }
126 return true;
127 }
128
129 final File parentFile = outputFile.getParentFile();
130 final Path parentPath = parentFile.toPath();
131 try {
132 Files.createDirectories(parentPath);
133 } catch (final IOException e) {
134 logger.warn("Failed to create parent directory: {}", parentFile.getAbsolutePath(), e);
135 return false;
136 }
137
138 return process(thumbnailId, responseData -> {
139 final String mimeType = responseData.getMimeType();
140 final String extension = getExtensionFromMimeType(mimeType);
141 final File tempFile = ComponentUtil.getSystemHelper().createTempFile("thumbnail_", extension);
142 try {
143 CopyUtil.copy(responseData.getResponseBody(), tempFile);
144
145 final String tempPath = tempFile.getAbsolutePath();
146 final String outputPath = outputFile.getAbsolutePath();
147 final List<String> cmdList = new ArrayList<>();
148 for (final String value : commandList) {
149 cmdList.add(expandPath(value.replace("${url}", tempPath)
150 .replace("${outputFile}", outputPath)
151 .replace("${mimetype}", mimeType != null ? mimeType : "")));
152 }
153
154 final Path outputPath2 = outputFile.toPath();
155 if (executeCommand(thumbnailId, cmdList) != 0) {
156 logger.warn("Failed to execute command for thumbnail ID: {}", thumbnailId);
157 try {
158 Files.deleteIfExists(outputPath2);
159 } catch (final IOException e) {
160 logger.warn("Failed to delete output file: {}", outputFile.getAbsolutePath(), e);
161 }
162 return false;
163 }
164
165 if (outputFile.isFile() && outputFile.length() == 0) {
166 logger.warn("Thumbnail file is empty: id={}", thumbnailId);
167 try {
168 if (Files.deleteIfExists(outputPath2)) {
169 logger.info("Deleted empty thumbnail file: {}", outputFile.getAbsolutePath());
170 }
171 } catch (final IOException e) {
172 logger.warn("Failed to delete empty thumbnail file: {}", outputFile.getAbsolutePath(), e);
173 }
174 updateThumbnailField(thumbnailId, StringUtil.EMPTY);
175 return false;
176 }
177
178 if (logger.isDebugEnabled()) {
179 logger.debug("Thumbnail File: {}", outputPath);
180 }
181 return true;
182 } catch (final Exception e) {
183 logger.warn("Failed to process thumbnail: id={}", thumbnailId, e);
184 updateThumbnailField(thumbnailId, StringUtil.EMPTY);
185 return false;
186 } finally {
187 if (tempFile != null) {
188 try {
189 Files.deleteIfExists(tempFile.toPath());
190 } catch (final IOException e) {
191 logger.debug("Failed to delete temp file: {}", tempFile.getAbsolutePath(), e);
192 }
193 }
194 }
195 });
196
197 }
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212 protected int executeCommand(final String thumbnailId, final List<String> cmdList) {
213 ProcessDestroyer task = null;
214 Process p = null;
215 InputStreamThread ist = null;
216 try {
217 final ProcessBuilder pb = new ProcessBuilder(cmdList);
218 pb.directory(baseDir);
219 pb.redirectErrorStream(true);
220
221 if (logger.isDebugEnabled()) {
222 logger.debug("Thumbnail Command: {}", cmdList);
223 }
224
225 p = pb.start();
226 ist = new InputStreamThread(p.getInputStream(), Charset.defaultCharset(), 0, s -> {
227 if (logger.isDebugEnabled()) {
228 logger.debug(s);
229 }
230 });
231 task = new ProcessDestroyer(p, ist, commandDestroyTimeout);
232 destoryTimer.schedule(task, commandTimeout);
233 ist.start();
234
235 if (logger.isDebugEnabled()) {
236 logger.debug("Waiting for {}.", getName());
237 }
238
239 if (p.waitFor(commandTimeout + commandDestroyTimeout, TimeUnit.MILLISECONDS)) {
240 if (task.isExecuted()) {
241
242 logger.warn("{} was timed out and destroyed.", getName());
243 } else {
244
245 final int exitValue = p.exitValue();
246 if (exitValue != 0) {
247 logger.warn("{} failed: exitCode={}, command={}", getName(), exitValue, commandList);
248 }
249
250 if (logger.isDebugEnabled()) {
251 logger.debug("{} finished: exitCode={}", getName(), exitValue);
252 }
253 return exitValue;
254 }
255 } else {
256
257 logger.warn("{} is unresponsive and could not be terminated within the safety timeout.", getName());
258 if (!task.isExecuted()) {
259 task.run();
260 }
261 }
262 } catch (final InterruptedException e) {
263 logger.warn("Interrupted generating thumbnail: id={}, command={}", thumbnailId, cmdList, e);
264 Thread.currentThread().interrupt();
265 } catch (final Exception e) {
266 logger.warn("Failed to generate thumbnail: id={}, command={}", thumbnailId, cmdList, e);
267 } finally {
268 if (task != null) {
269 task.cancel();
270 }
271 if (p != null && p.isAlive()) {
272 logger.warn("Process {} is still alive in finally block. Forcing destruction.", p);
273 if (task == null) {
274 task = new ProcessDestroyer(p, ist, commandDestroyTimeout);
275 }
276 if (!task.isExecuted()) {
277 task.run();
278 }
279 }
280 if (ist != null) {
281 ist.interrupt();
282 }
283 }
284 return -1;
285 }
286
287
288
289
290
291 protected static class ProcessDestroyer extends TimerTask {
292
293
294 private final Process p;
295
296
297 private final InputStreamThread ist;
298
299
300 private final AtomicBoolean executed = new AtomicBoolean(false);
301
302
303 private final long timeout;
304
305
306
307
308
309
310
311 public ProcessDestroyer(final Process p, final InputStreamThread ist, final long timeout) {
312 this.p = p;
313 this.ist = ist;
314 this.timeout = timeout;
315 }
316
317
318
319
320 @Override
321 public void run() {
322 if (!p.isAlive()) {
323 if (logger.isDebugEnabled()) {
324 logger.debug("Process {} is not alive.", p);
325 }
326 return;
327 }
328
329 if (executed.compareAndSet(false, true)) {
330 if (logger.isDebugEnabled()) {
331 logger.debug("Interrupting a stream thread.");
332 }
333 ist.interrupt();
334
335 CommonPoolUtil.execute(() -> {
336 try {
337 CloseableUtil.closeQuietly(p.getInputStream());
338 } catch (final Exception e) {
339 logger.warn("Could not close a process input stream.", e);
340 }
341 });
342 CommonPoolUtil.execute(() -> {
343 try {
344 CloseableUtil.closeQuietly(p.getErrorStream());
345 } catch (final Exception e) {
346 logger.warn("Could not close a process error stream.", e);
347 }
348 });
349 CommonPoolUtil.execute(() -> {
350 try {
351 CloseableUtil.closeQuietly(p.getOutputStream());
352 } catch (final Exception e) {
353 logger.warn("Could not close a process output stream.", e);
354 }
355 });
356
357 if (logger.isDebugEnabled()) {
358 logger.debug("Terminating process {}.", p);
359 }
360 try {
361 if (!p.destroyForcibly().waitFor(timeout, TimeUnit.MILLISECONDS)) {
362 logger.warn("Terminating process {} is timed out.", p);
363 } else if (logger.isDebugEnabled()) {
364 logger.debug("Terminated process {}.", p);
365 }
366 } catch (final Exception e) {
367 logger.warn("Failed to stop destroyer.", e);
368 }
369 } else {
370 if (logger.isDebugEnabled()) {
371 logger.debug("Process {} is already executed.", p);
372 }
373 }
374 }
375
376
377
378
379
380 public boolean isExecuted() {
381 return executed.get();
382 }
383 }
384
385
386
387
388
389 public void setCommandList(final List<String> commandList) {
390 this.commandList = commandList;
391 }
392
393
394
395
396
397 public void setCommandTimeout(final long commandTimeout) {
398 this.commandTimeout = commandTimeout;
399 }
400
401
402
403
404
405 public void setBaseDir(final File baseDir) {
406 this.baseDir = baseDir;
407 }
408
409
410
411
412
413 public void setCommandDestroyTimeout(final long commandDestroyTimeout) {
414 this.commandDestroyTimeout = commandDestroyTimeout;
415 }
416
417
418
419
420
421
422
423 protected String getExtensionFromMimeType(final String mimeType) {
424 if (mimeType == null) {
425 return "";
426 }
427 return switch (mimeType) {
428 case "image/gif" -> ".gif";
429 case "image/tiff" -> ".tiff";
430 case "image/svg+xml" -> ".svg";
431 case "image/jpeg" -> ".jpg";
432 case "image/png" -> ".png";
433 case "image/bmp", "image/x-windows-bmp", "image/x-ms-bmp" -> ".bmp";
434 case "image/vnd.adobe.photoshop", "image/photoshop", "application/x-photoshop", "application/photoshop" -> ".psd";
435 default -> "";
436 };
437 }
438
439 }