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}