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.PrintWriter;
019import java.io.StringWriter;
020import java.util.ArrayList;
021import java.util.Collection;
022import java.util.Date;
023import java.util.HashMap;
024import java.util.HashSet;
025import java.util.List;
026import java.util.Map;
027import java.util.Map.Entry;
028import java.util.Optional;
029import java.util.stream.Collectors;
030
031import javax.script.ScriptContext;
032import javax.script.ScriptEngine;
033import javax.script.ScriptEngineManager;
034import javax.script.ScriptException;
035import javax.script.SimpleBindings;
036
037import org.apache.avalon.framework.component.Component;
038import org.apache.avalon.framework.context.Context;
039import org.apache.avalon.framework.context.ContextException;
040import org.apache.avalon.framework.context.Contextualizable;
041import org.apache.avalon.framework.service.ServiceException;
042import org.apache.avalon.framework.service.ServiceManager;
043import org.apache.avalon.framework.service.Serviceable;
044import org.apache.cocoon.components.ContextHelper;
045import org.apache.commons.lang.exception.ExceptionUtils;
046import org.apache.commons.lang3.StringUtils;
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.runtime.i18n.I18nizableText;
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    private static final String __RIGHT_EXECUTE_SCRIPTS = "CORE_Rights_ExecuteScript";
061    
062    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()} ) } };";
063    private static final String __SCRIPT_INSERT_RUN_MAIN = "var __result; try { __result = main(); } finally { __cleanup_manager.cleanup() } __result";
064
065    /** The script binding extension point */
066    protected ScriptBindingExtensionPoint _scriptBindingEP;
067    
068    /** The right manager */
069    protected RightManager _rightManager;
070    
071    /** The current user provider*/
072    protected CurrentUserProvider _currentUserProvider;
073    
074    /** The avalon context */
075    protected Context _context;
076
077    @Override
078    public void service(ServiceManager serviceManager) throws ServiceException
079    {
080        _scriptBindingEP = (ScriptBindingExtensionPoint) serviceManager.lookup(ScriptBindingExtensionPoint.ROLE);
081        _rightManager = (RightManager) serviceManager.lookup(RightManager.ROLE);
082        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
083    }
084    
085    public void contextualize(Context context) throws ContextException
086    {
087        _context = context;
088    }
089    
090    /**
091     * Execute a script in the js admin console.
092     * @param arguments The map of arguments. Must contains at least the argument "script"
093     * @return A map of information on the script execution.
094     * @throws ScriptException If an error occurs
095     */
096    @Callable
097    public Map<String, Object> executeScript(Map<String, Object> arguments) throws ScriptException
098    {
099        return executeScript((String) arguments.get("script"));
100    }
101    
102    /**
103     * Execute a script in the js admin console.
104     * @param script The script as a String.
105     * @return A map of information on the script execution.
106     * @throws ScriptException If an error occurs
107     */
108    @Callable
109    public Map<String, Object> executeScript(String script) throws ScriptException
110    {
111        Map<String, Object> results = new HashMap<>();
112        
113        if (_rightManager.hasRight(_currentUserProvider.getUser(), __RIGHT_EXECUTE_SCRIPTS, "/application") != RightManager.RightResult.RIGHT_ALLOW)
114        {
115            // FIXME Currently unable to assign rights to a user in the _admin workspace
116            // throw new RightsException("Insufficient rights to execute a script");
117        }
118        
119        results.put("start", DateUtils.dateToString(new Date()));
120        
121        ScriptEngineManager mgr = new ScriptEngineManager();
122        ScriptEngine engine = mgr.getEngineByName("javascript");
123        
124        // Redirect output to a String
125        StringWriter output = new StringWriter();
126        PrintWriter pw = new PrintWriter(output);
127        ScriptContext context = engine.getContext();
128        context.setWriter(pw);
129        
130        StringWriter errorOutput = new StringWriter();
131        PrintWriter errorPw = new PrintWriter(errorOutput);
132        context.setErrorWriter(errorPw);
133        
134        context.setAttribute(ScriptEngine.FILENAME, "generated script", ScriptContext.GLOBAL_SCOPE);
135        
136        Map<String, Object> variables = new HashMap<>();
137        
138        List<ScriptBinding> scriptBindings = getScriptBindings();
139        
140        try
141        {
142            List<String> scriptText = new ArrayList<>();
143            scriptText.add(script);
144            scriptText.add(__SCRIPT_INSERT_CLEANUP_MANAGER);
145            setScriptBindings(variables, scriptText, scriptBindings);
146            scriptText.add(__SCRIPT_INSERT_RUN_MAIN);
147            
148            // Create bindings
149            SimpleBindings sb = new SimpleBindings();
150            for (Entry<String, Object> entry : variables.entrySet())
151            {
152                sb.put(entry.getKey(), entry.getValue());
153            }
154            
155            // Execute script
156            Object scriptResult = engine.eval(StringUtils.join(scriptText, "\n"), sb);
157            if (scriptResult != null)
158            {
159                results.put("result", processScriptResult(results, scriptBindings, scriptResult));
160            }
161        }
162        catch (Throwable t)
163        {
164            results.put("message", StringUtils.defaultString(t.getMessage()));
165            results.put("stacktrace", ExceptionUtils.getFullStackTrace(t));
166            getLogger().error("An exception occurred while running script", t);
167        }
168        finally
169        {
170            results.put("end", DateUtils.dateToString(new Date()));
171            // Script output and error.
172            results.put("output", output.toString());
173            results.put("error", errorOutput.toString());
174            
175            for (String extensionId : _scriptBindingEP.getExtensionsIds())
176            {
177                ScriptBinding scriptBinding = _scriptBindingEP.getExtension(extensionId);
178                
179                scriptBinding.cleanVariables(variables);
180            }
181        }
182        
183        return results;
184    }
185
186    private void setScriptBindings(Map<String, Object> variables, List<String> scriptText, List<ScriptBinding> scriptBindings)
187    {
188        for (ScriptBinding scriptBinding : scriptBindings)
189        {
190            Map<String, Object> scriptBindingVariables = scriptBinding.getVariables();
191            if (scriptBindingVariables != null)
192            {
193                variables.putAll(scriptBindingVariables);
194            }
195            
196            String scriptBindingFunctions = scriptBinding.getFunctions();
197            if (scriptBindingFunctions != null)
198            {
199                scriptText.add(scriptBindingFunctions);
200            }
201        }
202    }
203
204    /**
205     * Process the result of the script
206     * @param results The results map, available to fill
207     * @param scriptBindings The script bindings
208     * @param scriptResult The result of the script
209     * @return The result processed
210     * @throws ScriptException If an exception occurred
211     */
212    protected Object processScriptResult(Map<String, Object> results, List<ScriptBinding> scriptBindings, Object scriptResult) throws ScriptException
213    {
214        for (ScriptBinding scriptBinding : scriptBindings)
215        {
216            Object processedResult = scriptBinding.processScriptResult(scriptResult);
217            if (processedResult != null)
218            {
219                return processedResult;
220            }
221        }
222        
223        if (scriptResult instanceof Collection)
224        {
225            // Collection
226            List<Object> elements = new ArrayList<>();
227            for (Object obj : (Collection<?>) scriptResult)
228            {
229                elements.add(processScriptResult(results, scriptBindings, obj));
230            }
231            return elements;
232        }
233        else if (scriptResult instanceof Map)
234        {
235            // Map
236            Map<Object, Object> elements = new HashMap<>();
237            for (Object key : ((Map) scriptResult).keySet())
238            {
239                Object value = ((Map) scriptResult).get(key);
240                elements.put(processScriptResult(results, scriptBindings, key), processScriptResult(results, scriptBindings, value));
241            }
242            return elements;
243        }
244        else
245        {
246            return scriptResult.toString();
247        }
248    }
249    
250    /**
251     * Get the list of variables and functions descriptions currently registered for the Scripts.
252     * @return The list of variables and functions, as describes by the script bindings available.
253     */
254    @Callable
255    public Map<String, Object> getScriptBindingDescription()
256    {
257        Map<String, I18nizableText> variablesDesc = new HashMap<>();
258        Map<String, I18nizableText> functionsDesc = new HashMap<>();
259        List<ScriptBinding> scriptBindings = getScriptBindings();
260        for (ScriptBinding scriptBinding : scriptBindings)
261        {
262            Map<String, I18nizableText> scriptBindingVariablesDesc = scriptBinding.getVariablesDescriptions();
263            if (scriptBindingVariablesDesc != null)
264            {
265                // Warn if any variable was already declared by another ScriptBinding.
266                HashSet<String> intersection = new HashSet<>(variablesDesc.keySet());
267                intersection.retainAll(scriptBindingVariablesDesc.keySet());
268                if (intersection.size() > 0)
269                {
270                    for (String variable : intersection)
271                    {
272                        getLogger().warn("Multiple ScriptBinding use the same variable name : '" + variable + "'. Only one of these variables will be available.");
273                    }
274                }
275                
276                variablesDesc.putAll(scriptBindingVariablesDesc);
277            }
278            
279            Map<String, I18nizableText> scriptBindingFunctionsDesc = scriptBinding.getFunctionsDescriptions();
280            if (scriptBindingFunctionsDesc != null)
281            {
282                // Warn if any function was already declared by another ScriptBinding.
283                HashSet<String> intersection = new HashSet<>(functionsDesc.keySet());
284                intersection.retainAll(scriptBindingFunctionsDesc.keySet());
285                if (intersection.size() > 0)
286                {
287                    for (String function : intersection)
288                    {
289                        getLogger().warn("Multiple ScriptBinding use the same function name : '" + function + "'. Your scripts may not be able to run properly.");
290                    }
291                }
292                
293                functionsDesc.putAll(scriptBindingFunctionsDesc);
294            }
295        }
296        
297        HashMap<String, Object> result = new HashMap<>();
298        if (variablesDesc.size() > 0)
299        {
300            result.put("variables", variablesDesc);
301        }
302        if (functionsDesc.size() > 0)
303        {
304            result.put("functions", functionsDesc);
305        }
306        return result;
307    }
308    
309    /**
310     * Get the list of script bindings
311     * @return The list of script binding
312     */
313    protected List<ScriptBinding> getScriptBindings()
314    {
315        // By default, if there is no workspace, such as a script executed from a scheduled task,
316        // we want the script to be iso-functional with a script executed from the admin
317        String workspaceName = Optional.ofNullable((String) ContextHelper.getRequest(_context).getAttribute("workspaceName")).orElse("admin");
318        
319        return _scriptBindingEP.getExtensionsIds()
320                               .stream()
321                               .map(id -> _scriptBindingEP.getExtension(id))
322                               .filter(binding -> binding.getWorkspacePattern().matcher(workspaceName).matches())
323                               .collect(Collectors.toList());
324    }
325    
326    
327}