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}