001/*
002 *  Copyright 2016 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.core.ui.script;
017
018import java.io.ByteArrayOutputStream;
019import java.io.PrintStream;
020import java.io.PrintWriter;
021import java.util.ArrayList;
022import java.util.Collection;
023import java.util.Date;
024import java.util.HashMap;
025import java.util.Iterator;
026import java.util.List;
027import java.util.Map;
028import java.util.Map.Entry;
029import java.util.Optional;
030
031import javax.script.ScriptException;
032
033import org.apache.avalon.framework.component.Component;
034import org.apache.avalon.framework.context.Context;
035import org.apache.avalon.framework.context.ContextException;
036import org.apache.avalon.framework.context.Contextualizable;
037import org.apache.avalon.framework.service.ServiceException;
038import org.apache.avalon.framework.service.ServiceManager;
039import org.apache.avalon.framework.service.Serviceable;
040import org.apache.cocoon.components.ContextHelper;
041import org.apache.commons.lang3.StringUtils;
042import org.apache.commons.lang3.exception.ExceptionUtils;
043import org.graalvm.polyglot.HostAccess;
044import org.graalvm.polyglot.PolyglotException;
045import org.graalvm.polyglot.Source;
046import org.graalvm.polyglot.Value;
047
048import org.ametys.core.right.RightManager;
049import org.ametys.core.ui.Callable;
050import org.ametys.core.user.CurrentUserProvider;
051import org.ametys.core.util.DateUtils;
052import org.ametys.core.util.I18nUtils;
053import org.ametys.runtime.plugin.component.AbstractLogEnabled;
054
055/**
056 * Handler to describe and execute server scripts
057 */
058public class ScriptHandler extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
059{
060    /** Avalon role. */
061    public static final String ROLE = ScriptHandler.class.getName();
062    
063    /** Right for script execution */
064    protected static final String RIGHT_EXECUTE_SCRIPTS = "CORE_Rights_ExecuteScript";
065    
066    private static final String __SCRIPT_INSERT_CLEANUP_MANAGER = "var __cleanup_manager = { _registered:[], register: function (f) { this._registered.push(f) }, cleanup : function () { this._registered.forEach(function (f) {f()} ) } };";
067    private static final String __SCRIPT_INSERT_RUN_MAIN = "var __result; try { __result = main(); } finally { __cleanup_manager.cleanup() } __result";
068
069    /** The script binding extension point */
070    protected ScriptBindingExtensionPoint _scriptBindingEP;
071    
072    /** The right manager */
073    protected RightManager _rightManager;
074    
075    /** The current user provider*/
076    protected CurrentUserProvider _currentUserProvider;
077    
078    /** The i18n utils */
079    protected I18nUtils _i18nUtils;
080    
081    /** The avalon context */
082    protected Context _context;
083
084    @Override
085    public void service(ServiceManager serviceManager) throws ServiceException
086    {
087        _scriptBindingEP = (ScriptBindingExtensionPoint) serviceManager.lookup(ScriptBindingExtensionPoint.ROLE);
088        _rightManager = (RightManager) serviceManager.lookup(RightManager.ROLE);
089        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
090        _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE);
091    }
092    
093    @Override
094    public void contextualize(Context context) throws ContextException
095    {
096        _context = context;
097    }
098    
099    /**
100     * Builds the {@link ScriptExecArguments} object from the untyped JS object (seen as a Map in Java)
101     * @param arguments The untyped JS object
102     * @return the {@link ScriptExecArguments}
103     */
104    protected ScriptExecArguments buildExecArguments(Map<String, Object> arguments)
105    {
106        String script = (String) arguments.get("script");
107        return () -> script;
108    }
109    
110    /**
111     * Execute a script in the js admin console.
112     * @param script The script as a String.
113     * @return A map of information on the script execution.
114     * @throws ScriptException If an error occurs
115     */
116    @Callable(right = RIGHT_EXECUTE_SCRIPTS, context =  "/${WorkspaceName}")
117    public final Map<String, Object> executeScript(String script) throws ScriptException
118    {
119        return executeScript(Map.of("script", script));
120    }
121
122    /**
123     * Execute a script in the js console.
124     * @param arguments The map of arguments. Must contains at least the argument "script"
125     * @return A map of information on the script execution.
126     * @throws ScriptException If an error occurs
127     */
128    @Callable(right = RIGHT_EXECUTE_SCRIPTS, context =  "/${WorkspaceName}")
129    public Map<String, Object> executeScript(Map<String, Object> arguments) throws ScriptException
130    {
131        ScriptExecArguments execArgs = buildExecArguments(arguments);
132        String workspaceName = getWorkspaceName();
133        return _executeScript(execArgs, null, workspaceName);
134    }
135    
136    /**
137     * Execute a script in the js console.
138     * @param script The script as a String.
139     * @param scriptVariables map of variables that will be added to the script.
140     * @param workspaceName The workspace name
141     * @return A map of information on the script execution.
142     * @throws ScriptException If an error occurs
143     */
144    public Map<String, Object> executeScript(String script, Map<String, Object> scriptVariables, String workspaceName) throws ScriptException
145    {
146        ScriptExecArguments execArgs = buildExecArguments(Map.of("script", script));
147        return _executeScript(execArgs, scriptVariables, workspaceName);
148    }
149    
150    /**
151     * Execute a script in the js admin console.
152     * @param arguments The arguments for script execution
153     * @param scriptVariables map of variables that will be added to the script.
154     * @param workspaceName The workspace name
155     * @return A map of information on the script execution.
156     * @throws ScriptException If an error occurs
157     */
158    protected Map<String, Object> _executeScript(ScriptExecArguments arguments, Map<String, Object> scriptVariables, String workspaceName) throws ScriptException
159    {
160        String nonEmptyWorkspaceName = StringUtils.isEmpty(workspaceName) ? "admin" : workspaceName;
161        
162        Map<String, Object> results = new HashMap<>();
163        
164        results.put("start", DateUtils.dateToString(new Date()));
165        
166        var output = new ByteArrayOutputStream();
167        var errorOutput = new ByteArrayOutputStream();
168        
169        HostAccess hostAccess = HostAccess.newBuilder(HostAccess.ALL)
170                // JS arrays must be converted into Java Lists
171                .targetTypeMapping(List.class, Object.class, null, v -> v)
172                .build();
173        org.graalvm.polyglot.Context polyglotContext = org.graalvm.polyglot.Context.newBuilder("js")
174                .allowAllAccess(true)
175                .allowHostAccess(hostAccess)
176                .option("engine.WarnInterpreterOnly", "false")
177                .out(output)
178                .err(errorOutput)
179                .build();
180        
181        final String scriptName = "generated script";
182        
183        Map<String, Object> variables = new HashMap<>();
184        if (scriptVariables != null)
185        {
186            variables.putAll(scriptVariables);
187        }
188        
189        List<ScriptBinding> scriptBindings = _scriptBindingEP.getScriptBindings(nonEmptyWorkspaceName);
190        
191        try (polyglotContext)
192        {
193            List<String> scriptTexts = new ArrayList<>();
194            String script = arguments.script();
195            scriptTexts.add(script);
196            scriptTexts.add(__SCRIPT_INSERT_CLEANUP_MANAGER);
197            _setScriptBindings(variables, scriptTexts, scriptBindings, arguments);
198            scriptTexts.add(__SCRIPT_INSERT_RUN_MAIN);
199            
200            // Create bindings
201            Value jsBindings = polyglotContext.getBindings("js");
202            for (Entry<String, Object> entry : variables.entrySet())
203            {
204                jsBindings.putMember(entry.getKey(), entry.getValue());
205            }
206            
207            // Execute script
208            String scriptText = StringUtils.join(scriptTexts, "\n");
209            Source source = Source.newBuilder("js", scriptText, scriptName)
210                    .build();
211            Object scriptResult = polyglotContext.eval(source).as(Object.class);
212            if (scriptResult != null)
213            {
214                results.put("result", processScriptResult(results, scriptResult, arguments));
215            }
216        }
217        catch (Throwable t)
218        {
219            Throwable e = t;
220            if (t instanceof PolyglotException)
221            {
222                e = new PolyglotWithCauseException((PolyglotException) t);
223            }
224            
225            results.put("message", StringUtils.defaultString(e.getMessage()));
226            results.put("stacktrace", ExceptionUtils.getStackTrace(e));
227            getLogger().error("An exception occurred while running script", e);
228        }
229        finally
230        {
231            results.put("end", DateUtils.dateToString(new Date()));
232            // Script output and error.
233            results.put("output", output.toString()); // #toString() without Charset arg in order to use the platform's default character set (which is used by GraalVM)
234            results.put("error", errorOutput.toString()); // #toString() without Charset arg in order to use the platform's default character set (which is used by GraalVM)
235            
236            for (String extensionId : _scriptBindingEP.getExtensionsIds())
237            {
238                ScriptBinding scriptBinding = _scriptBindingEP.getExtension(extensionId);
239                
240                scriptBinding.cleanVariables(variables);
241            }
242        }
243        
244        return results;
245    }
246
247    private void _setScriptBindings(Map<String, Object> variables, List<String> scriptText, List<ScriptBinding> scriptBindings, ScriptExecArguments execArgs)
248    {
249        scriptText.add("Ametys = {};" 
250                + "Ametys.namespace = function(namespace, attributes) {" 
251                + "    var namespaces = namespace.split(\".\");" 
252                + "    var prefix = \"\";" 
253                + "    for (var i = 0; i < namespaces.length; i++)" 
254                + "    {" 
255                + "        var ns = (prefix ? prefix + \".\" : \"\") + namespaces[i]; " 
256                + "        eval(\"try {\" + ns + \" = \" + ns + \" || {} } catch (e) {\" + ns + \" = {}}\");" 
257                + "        prefix = ns;" 
258                + "    }"
259                + "    var newNamespace = eval(namespace);"
260                + "    if (attributes)"
261                + "    {"
262                + "        for (var i in attributes)"
263                + "        {"
264                + "            newNamespace[i] = attributes[i];"
265                + "        }"
266                + "    }"
267                + "    return newNamespace;"
268                + "}");
269        
270        for (ScriptBinding scriptBinding : scriptBindings)
271        {
272            Map<String, Object> scriptBindingVariables = scriptBinding.getVariables(execArgs);
273            if (scriptBindingVariables != null)
274            {
275                variables.putAll(scriptBindingVariables);
276            }
277            
278            String scriptVariables = scriptBinding.getVariablesScripts();
279            if (scriptVariables != null)
280            {
281                scriptText.add(scriptVariables);
282            }
283        }
284
285        for (ScriptBinding scriptBinding : scriptBindings)
286        {
287            String scriptBindingFunctions = scriptBinding.getFunctions();
288            if (scriptBindingFunctions != null)
289            {
290                scriptText.add(scriptBindingFunctions);
291            }
292        }
293    }
294    
295    /**
296     * Process the result of the script
297     * @param results The results map, available to fill
298     * @param scriptResult The result of the script
299     * @param execArgs The script execution arguments
300     * @return The processed result
301     */
302    protected Object processScriptResult(Map<String, Object> results, Object scriptResult, ScriptExecArguments execArgs)
303    {
304        return getProcessor().process(results, scriptResult);
305    }
306    
307    /**
308     * Returns the {@link ResultProcessor} used to process script result.
309     * @return the {@link ResultProcessor}.
310     */
311    protected ResultProcessor getProcessor()
312    {
313        return new ResultProcessor(); 
314    }
315    
316    private void _addToBinding(List<Map<String, Object>> descriptionsList, Map<? extends Object, ScriptBindingDocumentation> descriptions, String type)
317    {
318        if (descriptions != null)
319        {
320            for (Entry<? extends Object, ScriptBindingDocumentation> description : descriptions.entrySet())
321            {
322                Object name = description.getKey();
323                String id = type + "-" + name;
324                
325                Map<String, Object> scriptVariable = new HashMap<>();
326                scriptVariable.put("id", id);
327                scriptVariable.put("type", type);
328                scriptVariable.putAll(description.getValue().asMap());
329                
330                if (descriptionsList.stream().map(e -> e.get("id")).anyMatch(i -> id.equals(i)))
331                {
332                    getLogger().warn("Multiple ScriptBinding use the same " + type + " name : '" + name + "'. Only one of these " + type + "s will be available and the associated documentation may not match.");
333                }
334                else
335                {
336                    descriptionsList.add(scriptVariable);
337                }
338            }
339        }
340    }
341    
342    /**
343     * Get the list of variables and functions descriptions currently registered for the Scripts.
344     * @return The list of variables and functions, as describes by the script bindings available.
345     */
346    @Callable
347    public List<Map<String, Object>> getScriptBindingDescription()
348    {
349        List<Map<String, Object>> descriptionsList = new ArrayList<>();
350        
351        List<ScriptBinding> scriptBindings = _scriptBindingEP.getScriptBindings(getWorkspaceName());
352        for (ScriptBinding scriptBinding : scriptBindings)
353        {
354            _addToBinding(descriptionsList, scriptBinding.getVariablesDescriptions(), "variable");
355            _addToBinding(descriptionsList, scriptBinding.getFunctionsDescriptions(), "function");
356            _addToBinding(descriptionsList, scriptBinding.getTutorials(), "tutorial");
357        }
358        
359        return descriptionsList;
360    }
361    
362    /**
363     * Gets the workspace name
364     * @return the workspace name
365     */
366    protected String getWorkspaceName()
367    {
368        // By default, if there is no workspace, such as a script executed from a scheduled task,
369        // we want the script to be iso-functional with a script executed from the admin
370        return Optional.of(_context)
371                .map(ContextHelper::getRequest)
372                .map(req -> req.getAttribute("workspaceName"))
373                .filter(String.class::isInstance)
374                .map(String.class::cast)
375                .orElse("admin");
376    }
377    
378    /**
379     * A processor for scripts results.
380     */
381    protected static class ResultProcessor
382    {
383        /**
384         * Processes the script result.
385         * @param results the current results map.
386         * @param scriptResult the actual script Result.
387         * @return the processed script result.
388         */
389        protected Object process(Map<String, Object> results, Object scriptResult)
390        {
391            if (scriptResult instanceof Collection)
392            {
393                // Collection
394                List<Object> elements = new ArrayList<>();
395                for (Object obj : (Collection<?>) scriptResult)
396                {
397                    elements.add(process(results, obj));
398                }
399                
400                return elements;
401            }
402            else if (scriptResult instanceof Iterator)
403            {
404                List<Object> objs = new ArrayList<>();
405                Iterator it = (Iterator) scriptResult;
406                while (it.hasNext())
407                {
408                    objs.add(process(results, it.next()));
409                }
410                
411                return objs;
412            }
413            else if (scriptResult instanceof Map)
414            {
415                // Map
416                Map<Object, Object> elements = new HashMap<>();
417                for (Object key : ((Map) scriptResult).keySet())
418                {
419                    Object value = ((Map) scriptResult).get(key);
420                    Object elementKey = process(results, key);
421                    Object elementValue = process(results, value);
422                    elements.put(elementKey, elementValue);
423                }
424                
425                return elements;
426            }
427            else
428            {
429                return _processSingleObject(scriptResult);
430            }
431        }
432        
433        /**
434         * Transform an object that is not a collection
435         * @param scriptResult the result object
436         * @return the object transformed for return
437         */
438        protected Object _processSingleObject(Object scriptResult)
439        {
440            return scriptResult != null ? scriptResult.toString() : null;
441        }
442    }
443    
444    private static final class PolyglotWithCauseException extends RuntimeException
445    {
446        PolyglotException _initial;
447        
448        PolyglotWithCauseException(PolyglotException initial)
449        {
450            super(initial.getMessage());
451            _initial = initial;
452        }
453
454        private String _getStackTrace()
455        {
456            StringBuffer sb = new StringBuffer();
457            
458            if (_initial.isHostException())
459            {
460                sb.append(StringUtils.substringBefore(ExceptionUtils.getStackTrace(_initial), "Caused by host exception:"));
461                
462                Throwable subException = _initial.asHostException(); 
463                if (subException.getCause() != null)
464                {
465                    sb.append("Caused by: ");
466                    sb.append(ExceptionUtils.getStackTrace(subException.getCause()));
467                }
468            }
469            else
470            {
471                sb.append(ExceptionUtils.getStackTrace(_initial));
472            }
473            
474            return sb.toString();
475        }
476        
477        @Override
478        public void printStackTrace(PrintStream s)
479        {
480            s.append(_getStackTrace());
481        }
482        
483        @Override
484        public void printStackTrace(PrintWriter s)
485        {
486            s.append(_getStackTrace());
487        }
488    }
489}