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                .out(output)
177                .err(errorOutput)
178                .build();
179        
180        final String scriptName = "generated script";
181        
182        Map<String, Object> variables = new HashMap<>();
183        if (scriptVariables != null)
184        {
185            variables.putAll(scriptVariables);
186        }
187        
188        List<ScriptBinding> scriptBindings = _scriptBindingEP.getScriptBindings(nonEmptyWorkspaceName);
189        
190        try (polyglotContext)
191        {
192            List<String> scriptTexts = new ArrayList<>();
193            String script = arguments.script();
194            scriptTexts.add(script);
195            scriptTexts.add(__SCRIPT_INSERT_CLEANUP_MANAGER);
196            _setScriptBindings(variables, scriptTexts, scriptBindings, arguments);
197            scriptTexts.add(__SCRIPT_INSERT_RUN_MAIN);
198            
199            // Create bindings
200            Value jsBindings = polyglotContext.getBindings("js");
201            for (Entry<String, Object> entry : variables.entrySet())
202            {
203                jsBindings.putMember(entry.getKey(), entry.getValue());
204            }
205            
206            // Execute script
207            String scriptText = StringUtils.join(scriptTexts, "\n");
208            Source source = Source.newBuilder("js", scriptText, scriptName)
209                    .build();
210            Object scriptResult = polyglotContext.eval(source).as(Object.class);
211            if (scriptResult != null)
212            {
213                results.put("result", processScriptResult(results, scriptResult, arguments));
214            }
215        }
216        catch (Throwable t)
217        {
218            Throwable e = t;
219            if (t instanceof PolyglotException)
220            {
221                e = new PolyglotWithCauseException((PolyglotException) t);
222            }
223            
224            results.put("message", StringUtils.defaultString(e.getMessage()));
225            results.put("stacktrace", ExceptionUtils.getStackTrace(e));
226            getLogger().error("An exception occurred while running script", e);
227        }
228        finally
229        {
230            results.put("end", DateUtils.dateToString(new Date()));
231            // Script output and error.
232            results.put("output", output.toString()); // #toString() without Charset arg in order to use the platform's default character set (which is used by GraalVM)
233            results.put("error", errorOutput.toString()); // #toString() without Charset arg in order to use the platform's default character set (which is used by GraalVM)
234            
235            for (String extensionId : _scriptBindingEP.getExtensionsIds())
236            {
237                ScriptBinding scriptBinding = _scriptBindingEP.getExtension(extensionId);
238                
239                scriptBinding.cleanVariables(variables);
240            }
241        }
242        
243        return results;
244    }
245
246    private void _setScriptBindings(Map<String, Object> variables, List<String> scriptText, List<ScriptBinding> scriptBindings, ScriptExecArguments execArgs)
247    {
248        scriptText.add("Ametys = {};" 
249                + "Ametys.namespace = function(namespace, attributes) {" 
250                + "    var namespaces = namespace.split(\".\");" 
251                + "    var prefix = \"\";" 
252                + "    for (var i = 0; i < namespaces.length; i++)" 
253                + "    {" 
254                + "        var ns = (prefix ? prefix + \".\" : \"\") + namespaces[i]; " 
255                + "        eval(\"try {\" + ns + \" = \" + ns + \" || {} } catch (e) {\" + ns + \" = {}}\");" 
256                + "        prefix = ns;" 
257                + "    }"
258                + "    var newNamespace = eval(namespace);"
259                + "    if (attributes)"
260                + "    {"
261                + "        for (var i in attributes)"
262                + "        {"
263                + "            newNamespace[i] = attributes[i];"
264                + "        }"
265                + "    }"
266                + "    return newNamespace;"
267                + "}");
268        
269        for (ScriptBinding scriptBinding : scriptBindings)
270        {
271            Map<String, Object> scriptBindingVariables = scriptBinding.getVariables(execArgs);
272            if (scriptBindingVariables != null)
273            {
274                variables.putAll(scriptBindingVariables);
275            }
276            
277            String scriptVariables = scriptBinding.getVariablesScripts();
278            if (scriptVariables != null)
279            {
280                scriptText.add(scriptVariables);
281            }
282        }
283
284        for (ScriptBinding scriptBinding : scriptBindings)
285        {
286            String scriptBindingFunctions = scriptBinding.getFunctions();
287            if (scriptBindingFunctions != null)
288            {
289                scriptText.add(scriptBindingFunctions);
290            }
291        }
292    }
293    
294    /**
295     * Process the result of the script
296     * @param results The results map, available to fill
297     * @param scriptResult The result of the script
298     * @param execArgs The script execution arguments
299     * @return The processed result
300     */
301    protected Object processScriptResult(Map<String, Object> results, Object scriptResult, ScriptExecArguments execArgs)
302    {
303        return getProcessor().process(results, scriptResult);
304    }
305    
306    /**
307     * Returns the {@link ResultProcessor} used to process script result.
308     * @return the {@link ResultProcessor}.
309     */
310    protected ResultProcessor getProcessor()
311    {
312        return new ResultProcessor(); 
313    }
314    
315    private void _addToBinding(List<Map<String, Object>> descriptionsList, Map<? extends Object, ScriptBindingDocumentation> descriptions, String type)
316    {
317        if (descriptions != null)
318        {
319            for (Entry<? extends Object, ScriptBindingDocumentation> description : descriptions.entrySet())
320            {
321                Object name = description.getKey();
322                String id = type + "-" + name;
323                
324                Map<String, Object> scriptVariable = new HashMap<>();
325                scriptVariable.put("id", id);
326                scriptVariable.put("type", type);
327                scriptVariable.putAll(description.getValue().asMap());
328                
329                if (descriptionsList.stream().map(e -> e.get("id")).anyMatch(i -> id.equals(i)))
330                {
331                    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.");
332                }
333                else
334                {
335                    descriptionsList.add(scriptVariable);
336                }
337            }
338        }
339    }
340    
341    /**
342     * Get the list of variables and functions descriptions currently registered for the Scripts.
343     * @return The list of variables and functions, as describes by the script bindings available.
344     */
345    @Callable
346    public List<Map<String, Object>> getScriptBindingDescription()
347    {
348        List<Map<String, Object>> descriptionsList = new ArrayList<>();
349        
350        List<ScriptBinding> scriptBindings = _scriptBindingEP.getScriptBindings(getWorkspaceName());
351        for (ScriptBinding scriptBinding : scriptBindings)
352        {
353            _addToBinding(descriptionsList, scriptBinding.getVariablesDescriptions(), "variable");
354            _addToBinding(descriptionsList, scriptBinding.getFunctionsDescriptions(), "function");
355            _addToBinding(descriptionsList, scriptBinding.getTutorials(), "tutorial");
356        }
357        
358        return descriptionsList;
359    }
360    
361    /**
362     * Gets the workspace name
363     * @return the workspace name
364     */
365    protected String getWorkspaceName()
366    {
367        // By default, if there is no workspace, such as a script executed from a scheduled task,
368        // we want the script to be iso-functional with a script executed from the admin
369        return Optional.of(_context)
370                .map(ContextHelper::getRequest)
371                .map(req -> req.getAttribute("workspaceName"))
372                .filter(String.class::isInstance)
373                .map(String.class::cast)
374                .orElse("admin");
375    }
376    
377    /**
378     * A processor for scripts results.
379     */
380    protected static class ResultProcessor
381    {
382        /**
383         * Processes the script result.
384         * @param results the current results map.
385         * @param scriptResult the actual script Result.
386         * @return the processed script result.
387         */
388        protected Object process(Map<String, Object> results, Object scriptResult)
389        {
390            if (scriptResult instanceof Collection)
391            {
392                // Collection
393                List<Object> elements = new ArrayList<>();
394                for (Object obj : (Collection<?>) scriptResult)
395                {
396                    elements.add(process(results, obj));
397                }
398                
399                return elements;
400            }
401            else if (scriptResult instanceof Iterator)
402            {
403                List<Object> objs = new ArrayList<>();
404                Iterator it = (Iterator) scriptResult;
405                while (it.hasNext())
406                {
407                    objs.add(process(results, it.next()));
408                }
409                
410                return objs;
411            }
412            else if (scriptResult instanceof Map)
413            {
414                // Map
415                Map<Object, Object> elements = new HashMap<>();
416                for (Object key : ((Map) scriptResult).keySet())
417                {
418                    Object value = ((Map) scriptResult).get(key);
419                    Object elementKey = process(results, key);
420                    Object elementValue = process(results, value);
421                    elements.put(elementKey, elementValue);
422                }
423                
424                return elements;
425            }
426            else
427            {
428                return _processSingleObject(scriptResult);
429            }
430        }
431        
432        /**
433         * Transform an object that is not a collection
434         * @param scriptResult the result object
435         * @return the object transformed for return
436         */
437        protected Object _processSingleObject(Object scriptResult)
438        {
439            return scriptResult != null ? scriptResult.toString() : null;
440        }
441    }
442    
443    private static final class PolyglotWithCauseException extends RuntimeException
444    {
445        PolyglotException _initial;
446        
447        PolyglotWithCauseException(PolyglotException initial)
448        {
449            super(initial.getMessage());
450            _initial = initial;
451        }
452
453        private String _getStackTrace()
454        {
455            StringBuffer sb = new StringBuffer();
456            
457            if (_initial.isHostException())
458            {
459                sb.append(StringUtils.substringBefore(ExceptionUtils.getStackTrace(_initial), "Caused by host exception:"));
460                
461                Throwable subException = _initial.asHostException(); 
462                if (subException.getCause() != null)
463                {
464                    sb.append("Caused by: ");
465                    sb.append(ExceptionUtils.getStackTrace(subException.getCause()));
466                }
467            }
468            else
469            {
470                sb.append(ExceptionUtils.getStackTrace(_initial));
471            }
472            
473            return sb.toString();
474        }
475        
476        @Override
477        public void printStackTrace(PrintStream s)
478        {
479            s.append(_getStackTrace());
480        }
481        
482        @Override
483        public void printStackTrace(PrintWriter s)
484        {
485            s.append(_getStackTrace());
486        }
487    }
488}