View Javadoc
1   /*
2    * $Id$
3    *
4    * Licensed to the Apache Software Foundation (ASF) under one
5    * or more contributor license agreements.  See the NOTICE file
6    * distributed with this work for additional information
7    * regarding copyright ownership.  The ASF licenses this file
8    * to you under the Apache License, Version 2.0 (the
9    * "License"); you may not use this file except in compliance
10   * with the License.  You may obtain a copy of the License at
11   *
12   *  http://www.apache.org/licenses/LICENSE-2.0
13   *
14   * Unless required by applicable law or agreed to in writing,
15   * software distributed under the License is distributed on an
16   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
17   * KIND, either express or implied.  See the License for the
18   * specific language governing permissions and limitations
19   * under the License.
20   */
21  package org.apache.struts.scripting;
22  
23  import java.io.IOException;
24  import java.io.InputStream;
25  import java.io.UnsupportedEncodingException;
26  import java.net.URLDecoder;
27  import java.nio.charset.StandardCharsets;
28  import java.util.ArrayList;
29  import java.util.Arrays;
30  import java.util.Enumeration;
31  import java.util.LinkedHashMap;
32  import java.util.List;
33  import java.util.Locale;
34  import java.util.Map;
35  import java.util.Properties;
36  import java.util.concurrent.ConcurrentHashMap;
37  
38  import javax.script.Bindings;
39  import javax.script.ScriptContext;
40  import javax.script.ScriptEngineFactory;
41  import javax.script.ScriptEngineManager;
42  import javax.script.ScriptException;
43  
44  import org.apache.struts.action.Action;
45  import org.apache.struts.action.ActionErrors;
46  import org.apache.struts.action.ActionForm;
47  import org.apache.struts.action.ActionForward;
48  import org.apache.struts.action.ActionMapping;
49  import org.apache.struts.action.ActionMessages;
50  import org.slf4j.Logger;
51  import org.slf4j.LoggerFactory;
52  
53  import jakarta.servlet.ServletContext;
54  import jakarta.servlet.http.HttpServletRequest;
55  import jakarta.servlet.http.HttpServletResponse;
56  import jakarta.servlet.http.HttpSession;
57  
58  /**
59   * This Action uses scripts to perform its action. The scripting framework is
60   * Bean Scripting Framework 3.0 (JSR 223) which allows the scripts to be written
61   * many of the popular scripting languages including JavaScript, Perl, Python,
62   * and even VBA.
63   *
64   * <p>To determine what script will be executed, the "parameter" attribute of the
65   * action mapping should contain the name of the script relative to the web
66   * application root directory (i.e. https://server/app).</p>
67   *
68   * <p>Before the script completes, the next {@code ActionForward} needs to be
69   * specified. This can be done one of two ways:</p>
70   *
71   * <ol>
72   *   <li>Set {@code struts.forwardName} to the name of the forward</li>
73   *   <li>Set {@code struts.forward} to the actual ActionForward object</li>
74   *  </ol>
75   *
76   * <p>A number of pre-defined variables are available to the script:</p>
77   *
78   * <ul>
79   *   <li> {@code request} - The HTTP request</li>
80   *   <li> {@code response} - The HTTP response</li>
81   *   <li> {@code session} - The session</li>
82   *   <li> {@code application} - The servlet context</li>
83   *   <li> {@code struts} - A grouping of all Struts-related objects</li>
84   *   <li> {@code log} - A logging instance</li>
85   * </ul>
86   *
87   * <p>You can add your own variables by creating a {@link ScriptContextFilter}
88   * and configuring it in struts-scripting.properties:</p>
89   *
90   * <ul>
91   *   <li>{@code struts-scripting.filters.FILTER_NAME.class=FILTER_CLASS}
92   *       - The class implementing {@code ScriptContextFilter} where FILTER_NAME
93   *         is the name you are calling the filter.</li>
94   *   <li> {@code struts-scripting.filters.FILTER_NAME.PROPERTY_NAME=PROPERTY_VALUE}
95   *       - A property to be used by the filter.</li>
96   * </ul>
97   *
98   * <p>To use other scripting engines, add them to the classpath.</p>
99   *
100  * <p>To register more extensions to a scripting engine, create a file called
101  * {@code struts-scripting.properties} and add one propertie for each
102  * engine:</p>
103  *
104  * <ul>
105  *    <li>{@code struts-scripting.engine.ENGINE_NAME.extensions}
106  *        - A comma-delimited list of file extensions that will be used to
107  *        identify the engine to use to execute the script.</li>
108  * </ul>
109  *
110  * <p>This code was originally based off code from JPublish, but has since been
111  * almost completely rewritten.</p>
112  */
113 public class ScriptAction extends Action {
114     private static final long serialVersionUID = -383996253054413439L;
115 
116     /**
117      * The {@code Log} instance for this class.
118      */
119     private final static Logger LOG =
120         LoggerFactory.getLogger(ScriptAction.class);
121 
122     /** The entry-point to JSR223-scripting */
123     private static final ScriptEngineManager SCRIPT_ENGINE_MANAGER = new ScriptEngineManager();
124 
125     /**  The default path to the properties file. */
126     protected static final String PROPS_PATH = "/struts-scripting.properties";
127 
128     /**  The base property for alternate BSF engines. */
129     protected static final String ENGINE_BASE = "struts-scripting.engine.";
130 
131     /**  The base property for classes that put new variables in the context. */
132     protected static final String FILTERS_BASE = "struts-scripting.filters.";
133 
134     /**  A list of initialized filters. */
135     private static ScriptContextFilter[] filters = null;
136 
137     /**  Holds the "compiled" scripts and their information. */
138     private ConcurrentHashMap<String, Script> scripts = new ConcurrentHashMap<>();
139 
140     static {
141         final Properties props = new Properties();
142 
143         InputStream in = null;
144         String propsPath = PROPS_PATH;
145         try {
146             in = ScriptAction.class.getClassLoader().getResourceAsStream(propsPath);
147             if (in == null) {
148                 propsPath = "/struts-bsf.properties";
149                 in = ScriptAction.class.getClassLoader().getResourceAsStream(propsPath);
150                 if (in != null) {
151                     LOG.warn("The struts-bsf.properties file has been "
152                             + "deprecated.  Please use "
153                             + "struts-scripting.properties instead.");
154                 } else {
155                     LOG.warn("struts-scripting.properties not found, using "
156                              + "default engine mappings.");
157                 }
158             }
159 
160             if (in != null) {
161                 props.load(in);
162             }
163         } catch (Exception ex) {
164             LOG.warn("Unable to load struts-scripting.properties, using "
165                      + " default engine mappings.");
166         } finally {
167             if (in != null) {
168                 try {
169                     in.close();
170                 } catch (IOException ioe) {
171                     LOG.warn("Error closing '{}'", propsPath, ioe);
172                 }
173             }
174         }
175 
176         final List<ScriptEngineFactory> allSefs = SCRIPT_ENGINE_MANAGER.getEngineFactories();
177 
178         final int pos = ENGINE_BASE.length();
179         for (Enumeration<?> e = props.propertyNames(); e.hasMoreElements();) {
180             final String propName = e.nextElement().toString();
181             if (propName.startsWith(ENGINE_BASE) && propName.endsWith(".extensions")) {
182                 final String name = propName.substring(pos, propName.indexOf('.', pos));
183 
184                 ScriptEngineFactory[] sefs =
185                         allSefs.stream().filter((sef) -> sef.getNames().contains(name))
186                         .toArray(ScriptEngineFactory[]::new);
187 
188                 if (sefs.length == 0) {
189                     LOG.warn("No ScriptEngineFactory found - name: '{}'", name);
190                     continue;
191                 } else if (sefs.length > 1) {
192                     LOG.warn("More than one ScriptEngineFactory found, taking the first one - name: '{}'", name);
193                 }
194 
195                 final ScriptEngineFactory sef = sefs[0];
196                 LOG.info("Found ScriptingEngineFactory - name: {} language: {} {}",
197                     name, sef.getLanguageName(), sef.getLanguageVersion());
198 
199                 final String propValue = props.getProperty(propName).trim();
200                 final String[] exts = propValue.split(",");
201                 if (exts.length == 0) {
202                     continue;
203                 }
204 
205                 LOG.atInfo().log(() -> {
206                     final StringBuilder sb = new StringBuilder();
207                     sb.append("Registering extension");
208                     if (exts.length > 1) {
209                         sb.append('s');
210                     }
211                     sb
212                         .append(" to ScriptingEngineFactory - name: '")
213                         .append(name)
214                         .append("' ext");
215 
216                     if (exts.length > 1) {
217                         sb.append('s');
218                     }
219                     sb.append(": ");
220                     if (exts.length == 1) {
221                         sb
222                             .append('\'')
223                             .append(exts[0])
224                             .append('\'');
225                     } else {
226                         sb.append(Arrays.toString(exts));
227                     }
228                     return sb.toString();
229                 });
230 
231                 for (String ext : exts) {
232                     ext = ext.trim();
233                     if (!ext.isEmpty()) {
234                         SCRIPT_ENGINE_MANAGER.registerEngineExtension(ext, sef);
235                     }
236                 }
237             }
238         }
239         filters = loadFilters(props);
240     }
241 
242 
243     /**
244      * Executes the script.
245      *
246      * @param  mapping  The action mapping
247      * @param  form     The action form
248      * @param  request  The request object
249      * @param  response The response object
250      *
251      * @return The action forward
252      *
253      * @throws Exception If something goes wrong
254      */
255     public ActionForward execute(ActionMapping mapping,
256             ActionForm form,
257             HttpServletRequest request,
258             HttpServletResponse response)
259              throws Exception {
260 
261         final Map<String, String> params = new LinkedHashMap<>();
262         final String scriptName = parseScriptName(mapping.getParameter(), params);
263         if (scriptName == null) {
264             LOG.error("No script specified in the parameter attribute");
265             throw new Exception("No script specified");
266         }
267 
268         LOG.debug("Executing script: {}", scriptName);
269 
270         HttpSession session = request.getSession();
271         ServletContext application = getServlet().getServletContext();
272 
273         Script script = loadScript(scriptName, application);
274 
275         Bindings bindings = script.getBindings();
276         bindings.putAll(params);
277 
278         bindings.put("request", request);
279 
280         bindings.put("response", response);
281 
282         if (session == null) {
283             LOG.debug("HTTP session is null");
284         } else {
285             bindings.put("session", session);
286         }
287 
288         bindings.put("application", application);
289 
290         bindings.put("log", LOG);
291         StrutsInfo struts = new StrutsInfo(this, mapping, form,
292                 getResources(request));
293         bindings.put("struts", struts);
294 
295         final ScriptContext scriptContext = script.scriptEngine.getContext();
296         for (ScriptContextFilter filter : filters) {
297             filter.apply(scriptContext);
298         }
299 
300         script.eval();
301 
302         ActionForward af = struts.getForward();
303         return af;
304     }
305 
306 
307     /**
308      * Parses the script name and puts any URL parameters in
309      * the context.
310      *
311      * @param url The script URL consisting of a path and
312      *     optional parameters
313      * @param params A parameter-map to declare new parameters in
314      *
315      * @return The name of the script to execute
316      */
317     protected String parseScriptName(String url, Map<String, String> params) {
318 
319         LOG.debug("Parsing {}", url);
320 
321         if (url == null) {
322             return null;
323         }
324 
325         final String[] parsed = url.split("\\?", 2);
326 
327         if (parsed.length == 0) {
328             return null;
329         }
330 
331         if (parsed.length == 1) {
332             LOG.debug("No query string: {}", parsed[0]);
333             return parsed[0];
334         }
335 
336         LOG.debug("Found a query string");
337 
338         final String[] args = parsed[1].split("&");
339         for (String arg : args) {
340             final int i = arg.indexOf('=');
341             String key = urlDecode(i > 0 ? arg.substring(0, i) : arg);
342 
343             while (params.containsKey(key)) {
344                 LOG.warn("Script variable {} already exists", key);
345                 key = "_" + key;
346             }
347 
348             final String value = i > 0 && arg.length() > i + 1
349                     ? urlDecode(arg.substring(i + 1))
350                     : null;
351 
352             params.put(key, value);
353             LOG.debug("Registering param {} with value {}",
354                 key, value);
355         }
356 
357         return parsed[0];
358     }
359 
360     /**
361      * Decodes a {@code application/x-www-form-urlencoded} string
362      * using a the UTF-8-encoding scheme.
363      *
364      * @param s the {@code String} to decode
365      *
366      * @return the newly decoded {@code String}
367      *
368      * @see URLDecoder#decode(java.lang.String, java.lang.String)
369      */
370     private String urlDecode(final String s) {
371         if (s == null) {
372             return null;
373         }
374 
375         try {
376             return URLDecoder.decode(s, StandardCharsets.UTF_8.toString());
377         } catch (UnsupportedEncodingException e) {
378             // Should never thrown
379             LOG.error("URL-Decode: ", e);
380         }
381 
382         return null;
383     }
384 
385 
386     /**
387      *  Loads the script from cache if possible. Reloads if the script has been
388      *  recently modified.
389      *
390      * @param  name    The name of the script
391      * @param  context The servlet context
392      *
393      * @return         The script object
394      *
395      * @throws IOException if an I/O error occurs
396      * @throws ScriptException if compilation fails
397      */
398     protected Script loadScript(final String name, final ServletContext context)
399             throws IOException, ScriptException {
400 
401         final Script script = scripts.compute(name, (key, oldValue) -> {
402             if (oldValue == null) {
403                 return new Script(SCRIPT_ENGINE_MANAGER, context, key);
404             }
405 
406             oldValue.checkNewContent();
407             return oldValue;
408         });
409 
410         try {
411             script.checkExceptions();
412         } catch (IOException e) {
413             LOG.error("Unable to load script: {}", script.name, e);
414             throw e;
415         } catch (ScriptException e) {
416             LOG.error("Unable to compile script: {}", script.name, e);
417             throw e;
418         }
419         return script;
420     }
421 
422 
423     /**
424      *  Loads and initializes the filters.
425      *
426      *@param  props  The properties defining the filters
427      *@return        An array of the loaded filters
428      */
429     protected static ScriptContextFilter[] loadFilters(Properties props) {
430         ArrayList<ScriptContextFilter> list = new ArrayList<>();
431         for (Enumeration<?> e = props.propertyNames(); e.hasMoreElements();) {
432             final String propName = e.nextElement().toString().trim();
433             if (propName.startsWith(FILTERS_BASE) && propName.endsWith("class")) {
434                 String name = propName.substring(FILTERS_BASE.length(),
435                         propName.indexOf(".", FILTERS_BASE.length()));
436                 String clazz = props.getProperty(propName).trim();
437                 try {
438                     Class<? extends ScriptContextFilter> cls =
439                             Class.forName(clazz).asSubclass(ScriptContextFilter.class);
440                     ScriptContextFilter f = cls.getDeclaredConstructor().newInstance();
441                     f.init(name, props);
442                     list.add(f);
443                     LOG.info("Loaded {} filter: {}", name, clazz);
444                 } catch (Exception ex) {
445                     LOG.error("Unable to load {} filter: {}", name, clazz);
446                 }
447             }
448         }
449         return list.toArray(new ScriptContextFilter[0]);
450     }
451 
452 
453     // These methods seem necessary as some scripting engines are not able to
454     // access Action's protected methods.  Ugly? yes... any suggestions?
455 
456     /**
457      * Saves a token.
458      *
459      * @param  req  The request object
460      */
461     public void saveToken(HttpServletRequest req) {
462         super.saveToken(req);
463     }
464 
465     /**
466      * Checks to see if the request is cancelled.
467      *
468      * @param  req  The request object
469      * @return True if cancelled
470      */
471     public boolean isCancelled(HttpServletRequest req) {
472         return super.isCancelled(req);
473     }
474 
475     /**
476      * Checks to see if the token is valid.
477      *
478      * @param  req  The request object
479      * @return True if valid
480      */
481     public boolean isTokenValid(HttpServletRequest req) {
482         return super.isTokenValid(req);
483     }
484 
485     /**
486      * Resets the token.
487      *
488      * @param  req  The request object
489      */
490     public void resetToken(HttpServletRequest req) {
491         super.resetToken(req);
492     }
493 
494     /**
495      * Gets the locale.
496      *
497      * @param  req  The request object
498      * @return The locale value
499      */
500     public Locale getLocale(HttpServletRequest req) {
501         return super.getLocale(req);
502     }
503 
504     /**
505      * Saves the messages to the request.
506      *
507      * @param  req  The request object
508      * @param  mes  The action messages
509      */
510     public void saveMessages(HttpServletRequest req, ActionMessages mes) {
511         super.saveMessages(req, mes);
512     }
513 
514     /**
515      * Saves the errors to the request.
516      *
517      * @param  req    The request object
518      * @param  errs   The action errors
519      *
520      * @deprecated Use saveErrors(HttpServletRequest, ActionMessages) instead.
521      *     This will be removed after Struts 1.2.
522      */
523     @Deprecated
524     public void saveErrors(HttpServletRequest req, ActionErrors errs) {
525         super.saveErrors(req, errs);
526     }
527 }