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.script.groovy;
17  
18  import java.io.IOException;
19  import java.util.Collections;
20  import java.util.HashMap;
21  import java.util.Map;
22  import java.util.concurrent.ExecutionException;
23  
24  import org.apache.logging.log4j.LogManager;
25  import org.apache.logging.log4j.Logger;
26  import org.codelibs.core.lang.StringUtil;
27  import org.codelibs.fess.Constants;
28  import org.codelibs.fess.exception.JobProcessingException;
29  import org.codelibs.fess.opensearch.config.exentity.ScheduledJob;
30  import org.codelibs.fess.script.AbstractScriptEngine;
31  import org.codelibs.fess.util.ComponentUtil;
32  import org.lastaflute.di.core.factory.SingletonLaContainerFactory;
33  import org.lastaflute.job.LaJobRuntime;
34  
35  import com.google.common.cache.Cache;
36  import com.google.common.cache.CacheBuilder;
37  import com.google.common.cache.RemovalNotification;
38  
39  import groovy.lang.Binding;
40  import groovy.lang.GroovyClassLoader;
41  import groovy.lang.Script;
42  import jakarta.annotation.PostConstruct;
43  import jakarta.annotation.PreDestroy;
44  
45  /**
46   * Groovy script engine implementation that extends AbstractScriptEngine.
47   * This class provides support for executing Groovy scripts with parameter binding
48   * and DI container integration.
49   *
50   * <p>Thread Safety: This class is thread-safe. Each cached entry holds its own
51   * GroovyClassLoader. The cache uses Guava Cache with segment-based locking for
52   * lock-free concurrent reads. Each evaluate() call creates a new Script instance
53   * to ensure thread isolation of bindings.</p>
54   *
55   * <p>Note on class-level isolation: Compiled Script classes are cached and reused.
56   * Class-level state (static fields, metaclass mutations) persists across evaluations
57   * of the same script. In Fess, scripts are short expressions configured by
58   * administrators (e.g., "data1 &gt; 10", "10 * boost1 + boost2") and do not use
59   * static state, so this is acceptable.</p>
60   *
61   * <p>Resource Management: Each cached entry's GroovyClassLoader is closed on
62   * eviction via RemovalListener. All remaining entries are cleaned up via close() (@PreDestroy).</p>
63   */
64  public class GroovyEngine extends AbstractScriptEngine {
65      private static final Logger logger = LogManager.getLogger(GroovyEngine.class);
66  
67      /** Maximum number of compiled scripts to cache. Configurable via DI. */
68      protected int scriptCacheSize = 1000;
69  
70      /** Maximum length of script text included in warning log messages. Configurable via DI. */
71      protected int maxScriptLogLength = 200;
72  
73      /** Whether to log script execution details for auditing purposes. Configurable via DI. */
74      protected boolean scriptAuditLogEnabled;
75  
76      private Cache<String, CachedScript> scriptCache;
77  
78      /**
79       * Default constructor for GroovyEngine.
80       */
81      public GroovyEngine() {
82          super();
83          buildScriptCache();
84      }
85  
86      /**
87       * Rebuilds the script cache after DI injection.
88       * Called by the DI container after property injection.
89       */
90      @PostConstruct
91      public void init() {
92          buildScriptCache();
93          scriptAuditLogEnabled = ComponentUtil.available() && ComponentUtil.getFessConfig().isScriptAuditLogEnabled()
94                  && ComponentUtil.hasComponent("activityHelper");
95      }
96  
97      private void buildScriptCache() {
98          final Cache<String, CachedScript> oldCache = scriptCache;
99          scriptCache = CacheBuilder.newBuilder()
100                 .maximumSize(scriptCacheSize)
101                 .removalListener((final RemovalNotification<String, CachedScript> notification) -> {
102                     notification.getValue().close();
103                 })
104                 .build();
105         if (oldCache != null) {
106             oldCache.invalidateAll();
107         }
108     }
109 
110     /**
111      * Sets the maximum number of compiled scripts to cache.
112      *
113      * @param scriptCacheSize the cache size
114      */
115     public void setScriptCacheSize(final int scriptCacheSize) {
116         this.scriptCacheSize = scriptCacheSize;
117     }
118 
119     /**
120      * Sets the maximum length of script text included in warning log messages.
121      *
122      * @param maxScriptLogLength the max length
123      */
124     public void setMaxScriptLogLength(final int maxScriptLogLength) {
125         this.maxScriptLogLength = maxScriptLogLength;
126     }
127 
128     /**
129      * Evaluates a Groovy script template with the provided parameters.
130      *
131      * <p>This method caches compiled Script classes per script text.
132      * Each evaluation creates a new Script instance to ensure thread-safe binding isolation.
133      * The DI container is automatically injected into the binding map as "container".</p>
134      *
135      * @param template the Groovy script to evaluate (null-safe, returns null if empty)
136      * @param paramMap the parameters to bind to the script (null-safe, treated as empty map if null)
137      * @return the result of script evaluation, or null if the template is empty or evaluation fails
138      * @throws JobProcessingException if the script explicitly throws this exception
139      *         (allows scripts to signal job-specific errors that should propagate)
140      */
141     @Override
142     public Object evaluate(final String template, final Map<String, Object> paramMap) {
143         if (StringUtil.isBlank(template)) {
144             if (logger.isDebugEnabled()) {
145                 logger.debug("Template is blank, returning null");
146             }
147             return null;
148         }
149 
150         final Map<String, Object> safeParamMap = paramMap != null ? paramMap : Collections.emptyMap();
151 
152         final Map<String, Object> bindingMap = new HashMap<>(safeParamMap);
153         bindingMap.put("container", SingletonLaContainerFactory.getContainer());
154 
155         try {
156             final CachedScript cached = getOrCompile(template);
157             final Script script = cached.scriptClass.getDeclaredConstructor().newInstance();
158             script.setBinding(new Binding(bindingMap));
159 
160             if (logger.isDebugEnabled()) {
161                 logger.debug("Evaluating Groovy script: template={}", template);
162             }
163 
164             final Object result = script.run();
165             logScriptExecution(template, "success");
166             return result;
167         } catch (final JobProcessingException e) {
168             if (logger.isDebugEnabled()) {
169                 logger.debug("Script raised JobProcessingException", e);
170             }
171             logScriptExecution(template, "failure:" + e.getClass().getSimpleName());
172             throw e;
173         } catch (final Exception e) {
174             final String truncatedScript =
175                     template.length() > maxScriptLogLength ? template.substring(0, maxScriptLogLength) + "..." : template;
176             logger.warn("Failed to evaluate Groovy script: script(length={})={}, parameterKeys={}", template.length(), truncatedScript,
177                     safeParamMap.keySet(), e);
178             logScriptExecution(template, "failure:" + e.getClass().getSimpleName());
179             return null;
180         }
181     }
182 
183     @SuppressWarnings("unchecked")
184     private CachedScript getOrCompile(final String template) {
185         try {
186             return scriptCache.get(template, () -> {
187                 ClassLoader parentClassLoader = Thread.currentThread().getContextClassLoader();
188                 if (parentClassLoader == null) {
189                     parentClassLoader = GroovyEngine.class.getClassLoader();
190                 }
191                 final GroovyClassLoader classLoader = new GroovyClassLoader(parentClassLoader);
192                 try {
193                     final Class<? extends Script> scriptClass = (Class<? extends Script>) classLoader.parseClass(template);
194                     return new CachedScript(scriptClass, classLoader);
195                 } catch (final Exception e) {
196                     try {
197                         classLoader.clearCache();
198                         classLoader.close();
199                     } catch (final IOException closeEx) {
200                         logger.warn("Failed to close GroovyClassLoader after compilation failure", closeEx);
201                     }
202                     throw e;
203                 }
204             });
205         } catch (final ExecutionException e) {
206             throw (RuntimeException) e.getCause();
207         }
208     }
209 
210     /**
211      * Closes all cached GroovyClassLoaders and clears the script cache.
212      * Called by the DI container on shutdown.
213      */
214     @PreDestroy
215     public void close() {
216         scriptCache.invalidateAll();
217         scriptCache.cleanUp();
218     }
219 
220     /**
221      * Returns the name identifier for this script engine.
222      *
223      * @return "groovy" - the identifier used to register and retrieve this engine
224      */
225     @Override
226     protected String getName() {
227         return "groovy";
228     }
229 
230     /**
231      * Gets the current scheduled job from the thread-local job runtime.
232      *
233      * @return the scheduled job if available, null otherwise
234      */
235     protected ScheduledJob getCurrentScheduledJob() {
236         try {
237             if (!ComponentUtil.hasComponent("jobHelper")) {
238                 return null;
239             }
240             final LaJobRuntime runtime = ComponentUtil.getJobHelper().getJobRuntime();
241             if (runtime != null) {
242                 final Object job = runtime.getParameterMap().get(Constants.SCHEDULED_JOB);
243                 if (job instanceof ScheduledJob) {
244                     return (ScheduledJob) job;
245                 }
246             }
247         } catch (final Exception e) {
248             if (logger.isDebugEnabled()) {
249                 logger.debug("Failed to get scheduled job from thread local", e);
250             }
251         }
252         return null;
253     }
254 
255     /**
256      * Logs script execution to the audit log.
257      *
258      * @param script the script content that was executed
259      * @param result the execution result (e.g., "success" or "failure:ExceptionType")
260      */
261     protected void logScriptExecution(final String script, final String result) {
262         if (!scriptAuditLogEnabled) {
263             return;
264         }
265         try {
266             String source = "unknown";
267             String user = "system";
268 
269             final ScheduledJob job = getCurrentScheduledJob();
270             if (job != null) {
271                 source = "scheduler:" + job.getName();
272                 if (job.getCreatedBy() != null) {
273                     user = job.getCreatedBy();
274                 }
275             } else {
276                 try {
277                     user = ComponentUtil.getSystemHelper().getUsername();
278                 } catch (final Exception e) {
279                     // Ignore - background job context
280                 }
281             }
282 
283             ComponentUtil.getActivityHelper().scriptExecution(getName(), script, source, user, result);
284         } catch (final Exception e) {
285             if (logger.isDebugEnabled()) {
286                 logger.debug("Failed to log script execution", e);
287             }
288         }
289     }
290 
291     /**
292      * Holds a compiled Script class and its associated GroovyClassLoader.
293      * When evicted from the cache, close() releases the class loader resources.
294      */
295     private static class CachedScript {
296         final Class<? extends Script> scriptClass;
297         private final GroovyClassLoader classLoader;
298 
299         CachedScript(final Class<? extends Script> scriptClass, final GroovyClassLoader classLoader) {
300             this.scriptClass = scriptClass;
301             this.classLoader = classLoader;
302         }
303 
304         void close() {
305             try {
306                 classLoader.clearCache();
307                 classLoader.close();
308             } catch (final IOException e) {
309                 LogManager.getLogger(GroovyEngine.class).warn("Failed to close GroovyClassLoader", e);
310             }
311         }
312     }
313 
314 }