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.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   * Command-based thumbnail generator that executes external commands to create thumbnails.
44   * Uses external tools through command execution to generate thumbnail images from documents.
45   */
46  public class CommandGenerator extends BaseThumbnailGenerator {
47      private static final Logger logger = LogManager.getLogger(CommandGenerator.class);
48  
49      /** List of command strings to execute for thumbnail generation. */
50      protected List<String> commandList;
51  
52      /** Timeout for command execution in milliseconds. */
53      protected long commandTimeout = 30 * 1000L;// 30sec
54  
55      /** Timeout for destroying processes in milliseconds. */
56      protected long commandDestroyTimeout = 5 * 1000L;// 5sec
57  
58      /** Base directory for command execution. */
59      protected File baseDir;
60  
61      /** Timer for managing process destruction. */
62      private Timer destoryTimer;
63  
64      /**
65       * Default constructor for CommandGenerator.
66       */
67      public CommandGenerator() {
68          super();
69      }
70  
71      /**
72       * Initializes the command generator after construction.
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       * Updates timeout properties from system configuration.
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      * Destroys the command generator and cleanup resources.
103      */
104     @Override
105     public void destroy() {
106         destoryTimer.cancel();
107         destoryTimer = null;
108     }
109 
110     /**
111      * Generates a thumbnail for the given ID and saves it to the output file.
112      * @param thumbnailId The ID of the thumbnail to generate.
113      * @param outputFile The file where the thumbnail will be saved.
114      * @return True if thumbnail generation was successful, false otherwise.
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      * Executes a command to generate a thumbnail using the specified command list.
201      * <p>
202      * This method starts a process with the given command list, manages its execution,
203      * handles timeouts, and logs relevant information. It ensures the process is destroyed
204      * if it exceeds the allowed execution time or becomes unresponsive. The method also
205      * captures and logs the process output for debugging purposes.
206      * </p>
207      *
208      * @param thumbnailId the identifier for the thumbnail being generated
209      * @param cmdList the list of command arguments to execute
210      * @return the exit code of the process if it finishes normally; -1 if the process fails or is terminated
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                     // Process was killed by the timer.
242                     logger.warn("{} was timed out and destroyed.", getName());
243                 } else {
244                     // Process finished normally.
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                 // This is a secondary timeout, a safety net.
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; // Indicate failure
285     }
286 
287     /**
288      * Timer task for destroying processes that exceed their timeout.
289      * Handles graceful and forceful termination of thumbnail generation processes.
290      */
291     protected static class ProcessDestroyer extends TimerTask {
292 
293         /** The process to monitor and destroy. */
294         private final Process p;
295 
296         /** The input stream thread reading process output. */
297         private final InputStreamThread ist;
298 
299         /** Flag indicating if the destroyer has been executed. */
300         private final AtomicBoolean executed = new AtomicBoolean(false);
301 
302         /** Timeout for process destruction in milliseconds. */
303         private final long timeout;
304 
305         /**
306          * Constructor for ProcessDestroyer.
307          * @param p The process to monitor.
308          * @param ist The input stream thread.
309          * @param timeout The destruction timeout.
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          * Runs the process destroyer task to terminate the process.
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          * Checks if the destroyer has been executed.
378          * @return True if executed, false otherwise.
379          */
380         public boolean isExecuted() {
381             return executed.get();
382         }
383     }
384 
385     /**
386      * Sets the list of commands to execute for thumbnail generation.
387      * @param commandList The command list.
388      */
389     public void setCommandList(final List<String> commandList) {
390         this.commandList = commandList;
391     }
392 
393     /**
394      * Sets the command execution timeout.
395      * @param commandTimeout The timeout in milliseconds.
396      */
397     public void setCommandTimeout(final long commandTimeout) {
398         this.commandTimeout = commandTimeout;
399     }
400 
401     /**
402      * Sets the base directory for command execution.
403      * @param baseDir The base directory.
404      */
405     public void setBaseDir(final File baseDir) {
406         this.baseDir = baseDir;
407     }
408 
409     /**
410      * Sets the timeout for destroying processes.
411      * @param commandDestroyTimeout The destroy timeout in milliseconds.
412      */
413     public void setCommandDestroyTimeout(final long commandDestroyTimeout) {
414         this.commandDestroyTimeout = commandDestroyTimeout;
415     }
416 
417     /**
418      * Gets file extension from MIME type for creating temp files with proper extensions.
419      * This helps ImageMagick correctly identify file formats.
420      * @param mimeType The MIME type of the content.
421      * @return The file extension including the dot (e.g., ".gif"), or empty string if unknown.
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 }