diff --git a/AdvancedCore/pom.xml b/AdvancedCore/pom.xml index a3f17cc16..0b4ec710e 100644 --- a/AdvancedCore/pom.xml +++ b/AdvancedCore/pom.xml @@ -116,6 +116,10 @@ ${project.groupId}.advancedcore.folialib + + org.mozilla.javascript + ${project.groupId}.advancedcore.rhino + @@ -231,6 +235,11 @@ 2.12.2 provided + + org.mozilla + rhino + 1.9.1 + org.slf4j slf4j-simple diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptEngine.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptEngine.java index 2a784bdad..03a4cff83 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptEngine.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptEngine.java @@ -1,6 +1,7 @@ package com.bencodez.advancedcore.api.javascript; import java.util.HashMap; +import java.util.Map; import java.util.Map.Entry; import javax.script.ScriptEngine; @@ -16,17 +17,20 @@ import com.bencodez.simpleapi.messages.MessageAPI; public class JavascriptEngine { - private HashMap engineAPI; + private final HashMap engineAPI; + private final HashMap placeholders; + private OfflinePlayer placeholderPlayer; public JavascriptEngine() { engineAPI = new HashMap<>(); + placeholders = new HashMap<>(); } public JavascriptEngine addPlayer(AdvancedCoreUser user) { + placeholderPlayer = user.getOfflinePlayer(); addToEngine("PlayerName", user.getPlayerName()); addToEngine("PlayerUUID", user.getUUID()); addToEngine("AdvancedCoreUser", user); - // addToEngine("CommandSender", player); for (JavascriptPlaceholderRequest request : AdvancedCorePlugin.getInstance().getJavascriptEngineRequests()) { addToEngine(request.getStr(), request.getObject(user.getOfflinePlayer())); @@ -42,6 +46,7 @@ public JavascriptEngine addPlayer(CommandSender player) { addToEngine("CommandSender", player); if (player instanceof Player) { Player p = (Player) player; + placeholderPlayer = p; addToEngine("Player", p); addToEngine("PlayerName", p.getName()); addToEngine("PlayerUUID", p.getUniqueId().toString()); @@ -58,6 +63,7 @@ public JavascriptEngine addPlayer(CommandSender player) { } public JavascriptEngine addPlayer(OfflinePlayer player) { + placeholderPlayer = player; addToEngine("Player", player); addToEngine("PlayerName", player.getName()); addToEngine("PlayerUUID", player.getUniqueId().toString()); @@ -76,6 +82,7 @@ public JavascriptEngine addPlayer(OfflinePlayer player) { public JavascriptEngine addPlayer(Player player) { if (player != null) { + placeholderPlayer = player; addToEngine("Player", player); addToEngine("PlayerName", player.getName()); addToEngine("PlayerUUID", player.getUniqueId().toString()); @@ -90,6 +97,13 @@ public JavascriptEngine addPlayer(Player player) { return this; } + public JavascriptEngine addPlaceholders(Map placeholders) { + if (placeholders != null && !placeholders.isEmpty()) { + this.placeholders.putAll(placeholders); + } + return this; + } + public JavascriptEngine addToEngine(HashMap engineAPI) { if (engineAPI != null && !engineAPI.isEmpty()) { this.engineAPI.putAll(engineAPI); @@ -119,7 +133,7 @@ public boolean getBooleanValue(String expression) { } public Object getResult(String expression) { - if (!expression.equals("")) { + if (expression != null && !expression.isEmpty()) { if (!AdvancedCorePlugin.getInstance().getOptions().isJavascriptEngineEnabled()) { return null; } @@ -128,21 +142,32 @@ public Object getResult(String expression) { AdvancedCorePlugin.getInstance().debug("Failed to process javascript, engine == null"); return null; } - engine.put("Bukkit", Bukkit.getServer()); - engine.put("AdvancedCore", AdvancedCorePlugin.getInstance()); - engine.put("Console", Bukkit.getConsoleSender()); - engine.put("UserManager", AdvancedCorePlugin.getInstance().getUserManager()); - engine.put("RewardHandler", AdvancedCorePlugin.getInstance().getRewardHandler()); - engine.put("MessageAPI", MessageAPI.class); - engineAPI.putAll(AdvancedCorePlugin.getInstance().getJavascriptEngine()); - - for (Entry entry : engineAPI.entrySet()) { - engine.put(entry.getKey(), entry.getValue()); + HashMap placeholderBindings = new HashMap<>(); + String preparedExpression; + try { + preparedExpression = JavascriptPlaceholderBinder.bind(expression, placeholderPlayer, placeholders, + placeholderBindings); + } catch (IllegalArgumentException e) { + AdvancedCorePlugin.getInstance().getLogger() + .warning("Failed to safely prepare javascript placeholders: " + e.getMessage()); + AdvancedCorePlugin.getInstance().debug(e); + return null; } + HashMap evaluationBindings = new HashMap<>(); + evaluationBindings.put("Bukkit", Bukkit.getServer()); + evaluationBindings.put("AdvancedCore", AdvancedCorePlugin.getInstance()); + evaluationBindings.put("Console", Bukkit.getConsoleSender()); + evaluationBindings.put("UserManager", AdvancedCorePlugin.getInstance().getUserManager()); + evaluationBindings.put("RewardHandler", AdvancedCorePlugin.getInstance().getRewardHandler()); + evaluationBindings.put("MessageAPI", MessageAPI.class); + evaluationBindings.putAll(engineAPI); + evaluationBindings.putAll(placeholderBindings); + evaluationBindings.putAll(AdvancedCorePlugin.getInstance().getJavascriptEngine()); + try { - return engine.eval(expression); + return evaluateWithBindings(engine, preparedExpression, evaluationBindings); } catch (ScriptException e) { AdvancedCorePlugin.getInstance().getLogger().warning( "Error occoured while evaluating javascript, turn debug on to see stacktrace: " + e.toString()); @@ -152,6 +177,21 @@ public Object getResult(String expression) { return null; } + /** + * The configured engine is cached and shared by every JavascriptEngine wrapper. + * Keep binding writes and evaluation under the same lock so concurrent rewards + * cannot observe or overwrite one another's per-evaluation values. + */ + static Object evaluateWithBindings(ScriptEngine engine, String expression, Map bindings) + throws ScriptException { + synchronized (engine) { + for (Entry entry : bindings.entrySet()) { + engine.put(entry.getKey(), entry.getValue()); + } + return engine.eval(expression); + } + } + public String getStringValue(String expression) { try { Object result = getResult(expression); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderBinder.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderBinder.java new file mode 100644 index 000000000..1114a8c6f --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderBinder.java @@ -0,0 +1,379 @@ +package com.bencodez.advancedcore.api.javascript; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.bukkit.OfflinePlayer; +import org.mozilla.javascript.CompilerEnvirons; +import org.mozilla.javascript.Context; +import org.mozilla.javascript.Parser; +import org.mozilla.javascript.ast.AstRoot; +import org.mozilla.javascript.ast.Comment; +import org.mozilla.javascript.ast.RegExpLiteral; +import org.mozilla.javascript.ast.StringLiteral; +import org.mozilla.javascript.ast.TemplateCharacters; + +import com.bencodez.advancedcore.AdvancedCorePlugin; + +import me.clip.placeholderapi.PlaceholderAPI; + +/** + * Resolves placeholders inside a known, operator-authored JavaScript segment. + * Dynamic values in executable expression context are engine bindings; values + * inside existing literals are escaped for that literal. Rhino supplies the AST + * classification, so AdvancedCore does not maintain a JavaScript lexer. + */ +public final class JavascriptPlaceholderBinder { + // Keep the percent-token range aligned with PlaceholderAPI. Resolution below + // decides whether a percent-delimited candidate is really a placeholder; this + // is important because ordinary JavaScript such as 10%3%2 has the same shape. + private static final Pattern PLACEHOLDER = Pattern.compile("%([^%]+)%|(? placeholders, + Map bindings) { + return bind(expression, token -> resolve(token, player, placeholders), + value -> resolvePapiValue(value, player), bindings::put); + } + + static String bind(String expression, Function resolver, BiConsumer bindings) { + return bind(expression, resolver, Function.identity(), bindings); + } + + private static String bind(String expression, Function resolver, + Function decodedResolver, BiConsumer bindings) { + if (expression == null || expression.isEmpty()) { + return expression; + } + + Matcher matcher = PLACEHOLDER.matcher(expression); + List matches = new ArrayList<>(); + StringBuilder sanitized = new StringBuilder(expression); + int searchFrom = 0; + while (searchFrom < expression.length() && matcher.find(searchFrom)) { + String token = matcher.group(); + String decoded = JavascriptPlaceholderValue.decode(token); + String value = decoded == null ? resolver.apply(token) : decodedResolver.apply(decoded); + + // Percent signs are also JavaScript modulo operators, and brace-delimited + // text may be an object or block. Do not parse or rewrite a candidate that + // the configured placeholder sources did not actually resolve. + if (value == null || (decoded == null && value.equals(token))) { + // Retry after this opening delimiter instead of after the candidate's + // closing delimiter. An unresolved modulo-shaped candidate can overlap + // the opening percent of a real placeholder later in the expression. + searchFrom = matcher.start() + 1; + continue; + } + matches.add(new PlaceholderMatch(matcher.start(), matcher.end(), token, value)); + for (int i = matcher.start(); i < matcher.end(); i++) { + sanitized.setCharAt(i, 'p'); + } + searchFrom = matcher.end(); + } + if (matches.isEmpty()) { + return expression; + } + + JavascriptContexts contexts = JavascriptContexts.parse(sanitized.toString()); + String[] replacements = new String[matches.size()]; + int[] replacementStarts = new int[matches.size()]; + int bindingIndex = 0; + for (int i = 0; i < matches.size(); i++) { + PlaceholderMatch match = matches.get(i); + replacementStarts[i] = match.start; + if (contexts.containing(contexts.comments, match.start) != null) { + replacements[i] = match.token; + continue; + } + + Range regex = contexts.containing(contexts.regexes, match.start); + LiteralRange string = contexts.containingLiteral(match.start); + Range templateText = contexts.containing(contexts.templateText, match.start); + if (regex != null) { + replacementStarts[i] = literalReplacementStart(expression, match.start); + replacements[i] = escapeRegex(match.value, expression, regex, match.start); + } else if (templateText != null) { + replacementStarts[i] = literalReplacementStart(expression, match.start); + replacements[i] = escapeTemplate(match.value); + } else if (string != null) { + replacementStarts[i] = literalReplacementStart(expression, match.start); + replacements[i] = escapeString(match.value, string.quote); + } else { + String variable = VARIABLE_PREFIX + bindingIndex++; + bindings.accept(variable, coerce(match.value)); + replacements[i] = variable; + } + } + + StringBuilder result = new StringBuilder(expression); + for (int i = matches.size() - 1; i >= 0; i--) { + PlaceholderMatch match = matches.get(i); + result.replace(replacementStarts[i], match.end, replacements[i]); + } + return result.toString(); + } + + /** + * An odd authored backslash immediately before a placeholder already escapes + * the placeholder's first character. Consume that pending escape before + * inserting a separately escaped value. Otherwise the authored slash and the + * value's leading escape can pair off and reactivate a quote, template + * interpolation, or regex delimiter. + */ + private static int literalReplacementStart(String expression, int placeholderStart) { + int slashRunStart = placeholderStart; + while (slashRunStart > 0 && expression.charAt(slashRunStart - 1) == '\\') { + slashRunStart--; + } + return ((placeholderStart - slashRunStart) & 1) == 1 ? placeholderStart - 1 : placeholderStart; + } + + private static String resolve(String token, OfflinePlayer player, Map placeholders) { + if (placeholders != null) { + String name = token.substring(1, token.length() - 1); + for (Entry entry : placeholders.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + return resolvePapiValue(entry.getValue(), player); + } + } + } + + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + if (token.startsWith("%") && player != null && plugin != null && plugin.isPlaceHolderAPIEnabled()) { + String resolved = PlaceholderAPI.setPlaceholders(player, token); + if (resolved != null && !resolved.equals(token)) { + return resolved; + } + } + return token; + } + + private static String resolvePapiValue(String value, OfflinePlayer player) { + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + if (value != null && player != null && plugin != null && plugin.isPlaceHolderAPIEnabled()) { + String resolved = PlaceholderAPI.setPlaceholders(player, value); + if (resolved != null) { + return resolved; + } + } + return value; + } + + private static Object coerce(String value) { + if (value.equalsIgnoreCase("true") || value.equalsIgnoreCase("false")) { + return Boolean.valueOf(value); + } + if (INTEGER.matcher(value).matches()) { + try { + return Long.valueOf(value); + } catch (NumberFormatException ignored) { + } + } + if (DECIMAL.matcher(value).matches()) { + try { + return Double.valueOf(value); + } catch (NumberFormatException ignored) { + } + } + return value; + } + + private static String escapeString(String value, char quote) { + StringBuilder result = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char current = value.charAt(i); + switch (current) { + case '\\': + result.append("\\\\"); + break; + case '\n': + result.append("\\n"); + break; + case '\r': + result.append("\\r"); + break; + case '\u2028': + result.append("\\u2028"); + break; + case '\u2029': + result.append("\\u2029"); + break; + default: + if (current == quote) { + result.append('\\'); + } + result.append(current); + break; + } + } + return result.toString(); + } + + private static String escapeTemplate(String value) { + return value.replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${") + .replace("\r", "\\r").replace("\n", "\\n").replace("\u2028", "\\u2028") + .replace("\u2029", "\\u2029"); + } + + private static String escapeRegex(String value, String expression, Range regex, int placeholderStart) { + boolean characterClass = false; + boolean escaped = false; + for (int i = regex.start + 1; i < placeholderStart; i++) { + char current = expression.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (current == '\\') { + escaped = true; + } else if (current == '[') { + characterClass = true; + } else if (current == ']') { + characterClass = false; + } + } + + String special = characterClass ? "\\/]^-" : "\\/.*+?^${}()|[]"; + StringBuilder result = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char current = value.charAt(i); + if (current == '\n') { + result.append("\\n"); + } else if (current == '\r') { + result.append("\\r"); + } else if (current == '\u2028') { + result.append("\\u2028"); + } else if (current == '\u2029') { + result.append("\\u2029"); + } else { + if (special.indexOf(current) >= 0) { + result.append('\\'); + } + result.append(current); + } + } + return result.toString(); + } + + private static final class PlaceholderMatch { + private final int start; + private final int end; + private final String token; + private final String value; + + private PlaceholderMatch(int start, int end, String token, String value) { + this.start = start; + this.end = end; + this.token = token; + this.value = value; + } + } + + private static class Range { + private final int start; + private final int end; + + private Range(int start, int end) { + this.start = start; + this.end = end; + } + + final boolean contains(int position) { + return position >= start && position < end; + } + } + + private static final class LiteralRange extends Range { + private final char quote; + + private LiteralRange(int start, int end, char quote) { + super(start, end); + this.quote = quote; + } + } + + private static final class JavascriptContexts { + private final List strings = new ArrayList<>(); + private final List regexes = new ArrayList<>(); + private final List templateText = new ArrayList<>(); + private final List comments = new ArrayList<>(); + + private static JavascriptContexts parse(String source) { + try { + CompilerEnvirons environs = new CompilerEnvirons(); + environs.setLanguageVersion(Context.VERSION_ES6); + environs.setRecordingComments(true); + environs.setRecordingLocalJsDocComments(true); + environs.setRecoverFromErrors(false); + + AstRoot root = new Parser(environs).parse(source, "AdvancedCore", 1); + JavascriptContexts contexts = new JavascriptContexts(); + root.visit(node -> { + int start = node.getAbsolutePosition(); + int end = start + node.getLength(); + if (node instanceof StringLiteral) { + contexts.strings.add(new LiteralRange(start, end, + ((StringLiteral) node).getQuoteCharacter())); + } else if (node instanceof RegExpLiteral) { + contexts.regexes.add(new Range(start, end)); + } else if (node instanceof TemplateCharacters) { + contexts.templateText.add(new Range(start, end)); + } + return true; + }); + + if (root.getComments() != null) { + for (Comment comment : root.getComments()) { + int start = comment.getAbsolutePosition(); + contexts.comments.add(new Range(start, start + comment.getLength())); + } + } + contexts.sort(); + return contexts; + } catch (RuntimeException e) { + throw new IllegalArgumentException( + "Unable to safely parse JavaScript containing placeholders: " + e.getMessage(), e); + } + } + + private Range containing(List ranges, int position) { + for (Range range : ranges) { + if (range.contains(position)) { + return range; + } + } + return null; + } + + private LiteralRange containingLiteral(int position) { + for (LiteralRange range : strings) { + if (range.contains(position)) { + return range; + } + } + return null; + } + + private void sort() { + Comparator comparator = Comparator.comparingInt(range -> range.start); + strings.sort(comparator); + regexes.sort(comparator); + templateText.sort(comparator); + comments.sort(comparator); + } + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderValue.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderValue.java new file mode 100644 index 000000000..41b49eb8f --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderValue.java @@ -0,0 +1,35 @@ +package com.bencodez.advancedcore.api.javascript; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * Carries custom placeholder output through an authored JavaScript segment as + * inert data until the JavaScript binder can bind or escape it safely. + */ +public final class JavascriptPlaceholderValue { + private static final String PREFIX = "%__advancedcore_bound_"; + private static final String SUFFIX = "%"; + + private JavascriptPlaceholderValue() { + } + + public static String encode(String value) { + String safeValue = value == null ? "" : value; + String encoded = Base64.getUrlEncoder().withoutPadding() + .encodeToString(safeValue.getBytes(StandardCharsets.UTF_8)); + return PREFIX + encoded + SUFFIX; + } + + static String decode(String token) { + if (token == null || !token.startsWith(PREFIX) || !token.endsWith(SUFFIX)) { + return null; + } + String encoded = token.substring(PREFIX.length(), token.length() - SUFFIX.length()); + try { + return new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8); + } catch (IllegalArgumentException ignored) { + return null; + } + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptTextTemplate.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptTextTemplate.java new file mode 100644 index 000000000..4a438c5d6 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptTextTemplate.java @@ -0,0 +1,150 @@ +package com.bencodez.advancedcore.api.javascript; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * Splits configured text into literal text and JavaScript segments before any + * placeholder replacement occurs. + *

+ * Only {@code [Javascript=...]} markers present in the original input become + * executable segments. Marker-looking text produced later by a placeholder or + * JavaScript result is neutralized and remains ordinary text. + */ +public final class JavascriptTextTemplate { + private static final String MARKER = "[Javascript="; + + private final List segments; + + private JavascriptTextTemplate(List segments) { + this.segments = segments; + } + + public static JavascriptTextTemplate parse(String source) { + List segments = new ArrayList<>(); + if (source == null || source.isEmpty()) { + segments.add(Segment.text(source)); + return new JavascriptTextTemplate(segments); + } + + int cursor = 0; + while (cursor < source.length()) { + int start = indexOfIgnoreCase(source, MARKER, cursor); + if (start < 0) { + segments.add(Segment.text(source.substring(cursor))); + break; + } + + if (start > cursor) { + segments.add(Segment.text(source.substring(cursor, start))); + } + + int bodyStart = start + MARKER.length(); + int end = source.indexOf(']', bodyStart); + if (end < 0) { + segments.add(Segment.text(source.substring(start))); + break; + } + + segments.add(Segment.javascript(source.substring(start, bodyStart), + source.substring(bodyStart, end))); + cursor = end + 1; + } + + if (segments.isEmpty()) { + segments.add(Segment.text(source)); + } + return new JavascriptTextTemplate(segments); + } + + /** + * Transforms literal and JavaScript content while preserving authored marker + * boundaries. + */ + public String transform(Function textTransformer, + Function javascriptTransformer) { + StringBuilder result = new StringBuilder(); + for (Segment segment : segments) { + if (segment.javascript) { + result.append(segment.markerPrefix) + .append(apply(javascriptTransformer, segment.value)) + .append(']'); + } else { + result.append(neutralizeGeneratedMarkers(apply(textTransformer, segment.value))); + } + } + return result.toString(); + } + + /** + * Evaluates authored JavaScript segments and returns plain text. Any marker + * produced by transformed text or a script result is neutralized so a second + * processing pass cannot execute it. + */ + public String evaluate(Function textTransformer, + Function javascriptEvaluator) { + StringBuilder result = new StringBuilder(); + for (Segment segment : segments) { + String value = segment.javascript + ? apply(javascriptEvaluator, segment.value) + : apply(textTransformer, segment.value); + result.append(value); + } + return neutralizeGeneratedMarkers(result.toString()); + } + + public static String neutralizeGeneratedMarkers(String text) { + if (text == null || text.isEmpty()) { + return text; + } + + StringBuilder result = new StringBuilder(text.length()); + int cursor = 0; + while (cursor < text.length()) { + int start = indexOfIgnoreCase(text, MARKER, cursor); + if (start < 0) { + result.append(text, cursor, text.length()); + break; + } + result.append(text, cursor, start + MARKER.length() - 1).append(" ="); + cursor = start + MARKER.length(); + } + return result.toString(); + } + + private static String apply(Function transformer, String value) { + String transformed = transformer == null ? value : transformer.apply(value); + return transformed == null ? "" : transformed; + } + + private static int indexOfIgnoreCase(String text, String target, int fromIndex) { + int max = text.length() - target.length(); + for (int i = Math.max(0, fromIndex); i <= max; i++) { + if (text.regionMatches(true, i, target, 0, target.length())) { + return i; + } + } + return -1; + } + + private static final class Segment { + private final boolean javascript; + private final String markerPrefix; + private final String value; + + private Segment(boolean javascript, String markerPrefix, String value) { + this.javascript = javascript; + this.markerPrefix = markerPrefix; + this.value = value; + } + + private static Segment text(String value) { + return new Segment(false, null, value == null ? "" : value); + } + + private static Segment javascript(String markerPrefix, String value) { + return new Segment(true, markerPrefix, value); + } + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/PlaceholderUtils.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/PlaceholderUtils.java index 8e74bb82d..c5718f8c1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/PlaceholderUtils.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/PlaceholderUtils.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.Map.Entry; +import java.util.function.Function; import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; @@ -10,6 +11,8 @@ import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.javascript.JavascriptEngine; +import com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderValue; +import com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.simpleapi.messages.MessageAPI; @@ -209,8 +212,9 @@ public static ArrayList replaceJavascript(Player player, ArrayList replacePlaceHolder(ArrayList list, HashMap placeholders) { @@ -292,21 +258,16 @@ public static ArrayList replacePlaceHolder(ArrayList list, HashM } public static String replacePlaceHolder(String str, HashMap placeholders) { - if (placeholders != null) { - for (Entry entry : placeholders.entrySet()) { - str = replacePlaceHolder(str, entry.getKey(), entry.getValue()); - } - } - return str; + return replacePlaceHolder(str, placeholders, true); } public static String replacePlaceHolder(String str, HashMap placeholders, boolean ignoreCase) { - if (placeholders != null) { - for (Entry entry : placeholders.entrySet()) { - str = replacePlaceHolder(str, entry.getKey(), entry.getValue(), ignoreCase); - } + if (str == null || placeholders == null || placeholders.isEmpty()) { + return str; } - return str; + return JavascriptTextTemplate.parse(str).transform( + value -> replacePlaceHolderMapRaw(value, placeholders, ignoreCase), + value -> replacePlaceHolderMapEncoded(value, placeholders, ignoreCase)); } /** @@ -322,14 +283,44 @@ public static String replacePlaceHolder(String str, String toReplace, String rep } public static String replacePlaceHolder(String str, String toReplace, String replaceWith, boolean ignoreCase) { + if (str == null) { + return null; + } + return JavascriptTextTemplate.parse(str).transform( + value -> replacePlaceHolderRaw(value, toReplace, replaceWith, ignoreCase), + value -> replacePlaceHolderRaw(value, toReplace, JavascriptPlaceholderValue.encode(replaceWith), + ignoreCase)); + } + + private static String replacePlaceHolderMapRaw(String str, HashMap placeholders, + boolean ignoreCase) { + String result = str; + for (Entry entry : placeholders.entrySet()) { + result = replacePlaceHolderRaw(result, entry.getKey(), entry.getValue(), ignoreCase); + } + return result; + } + + private static String replacePlaceHolderMapEncoded(String str, HashMap placeholders, + boolean ignoreCase) { + String result = str; + for (Entry entry : placeholders.entrySet()) { + result = replacePlaceHolderRaw(result, entry.getKey(), JavascriptPlaceholderValue.encode(entry.getValue()), + ignoreCase); + } + return result; + } + + private static String replacePlaceHolderRaw(String str, String toReplace, String replaceWith, boolean ignoreCase) { + String safeReplacement = replaceWith == null ? "" : replaceWith; if (ignoreCase) { - return MessageAPI.replaceIgnoreCase(MessageAPI.replaceIgnoreCase(str, "%" + toReplace + "%", replaceWith), - "\\{" + toReplace + "\\}", replaceWith); + return MessageAPI.replaceIgnoreCase( + MessageAPI.replaceIgnoreCase(str, "%" + toReplace + "%", safeReplacement), + "\\{" + toReplace + "\\}", safeReplacement); } str = str.replaceAll("\\{", "%"); str = str.replaceAll("\\}", "%"); - str = str.replace("%" + toReplace + "%", replaceWith); - return str; + return str.replace("%" + toReplace + "%", safeReplacement); } public static ArrayList replacePlaceHolders(ArrayList list, Player p) { @@ -349,11 +340,21 @@ public static ArrayList replacePlaceHolders(OfflinePlayer player, ArrayL } public static String replacePlaceHolders(OfflinePlayer player, String text) { - if (player == null) { + return replacePlaceHolders(player, text, false); + } + + private static String replacePlaceHolders(OfflinePlayer player, String text, + boolean replaceJavascriptSegments) { + if (player == null || text == null || text.isEmpty()) { return text; } if (AdvancedCorePlugin.getInstance().isPlaceHolderAPIEnabled()) { - return PlaceholderAPI.setPlaceholders(player, text); + Function replacement = value -> PlaceholderAPI.setPlaceholders(player, value); + Function javascriptReplacement = replaceJavascriptSegments + ? value -> JavascriptTextTemplate.neutralizeGeneratedMarkers(replacement.apply(value)) + : Function.identity(); + return JavascriptTextTemplate.parse(text).transform( + replacement, javascriptReplacement); } return text; } @@ -366,13 +367,7 @@ public static String replacePlaceHolders(OfflinePlayer player, String text) { * @return the string */ public static String replacePlaceHolders(Player player, String text) { - if (player == null) { - return text; - } - if (AdvancedCorePlugin.getInstance().isPlaceHolderAPIEnabled()) { - return PlaceholderAPI.setPlaceholders(player, text); - } - return text; + return replacePlaceHolders((OfflinePlayer) player, text); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java index 3af8e73a5..d80fcd9f6 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java @@ -14,7 +14,6 @@ import com.bencodez.advancedcore.api.inventory.editgui.valuetypes.EditGUIValueList; import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.javascript.JavascriptEngine; -import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.rewards.DefinedReward; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardBuilder; @@ -37,10 +36,10 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { if (!list.isEmpty()) { - JavascriptEngine engine = new JavascriptEngine().addPlayer(user.getOfflinePlayer()); + JavascriptEngine engine = new JavascriptEngine().addPlayer(user.getOfflinePlayer()) + .addPlaceholders(placeholders); for (String script : list) { - String expression = PlaceholderUtils.replacePlaceHolders(user.getOfflinePlayer(), script); - engine.execute(PlaceholderUtils.replacePlaceHolder(expression, placeholders)); + engine.execute(script); } } return null; @@ -61,9 +60,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat HashMap placeholders) { if (section.getBoolean("Enabled")) { String expression = section.getString("Expression"); - expression = PlaceholderUtils.replacePlaceHolders(user.getOfflinePlayer(), expression); - if (new JavascriptEngine().addPlayer(user.getOfflinePlayer()) - .getBooleanValue(PlaceholderUtils.replacePlaceHolder(expression, placeholders))) { + if (new JavascriptEngine().addPlayer(user.getOfflinePlayer()).addPlaceholders(placeholders) + .getBooleanValue(expression)) { new RewardBuilder(section, "TrueRewards").withPrefix(reward.getName() + ".Javascript").send(user); } else { new RewardBuilder(section, "FalseRewards").withPrefix(reward.getName() + ".Javascript").send(user); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementJavascript.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementJavascript.java index 22be9c421..7bf479980 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementJavascript.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementJavascript.java @@ -8,7 +8,6 @@ import com.bencodez.advancedcore.api.inventory.editgui.valuetypes.EditGUIValueString; import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.javascript.JavascriptEngine; -import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardEditData; import com.bencodez.advancedcore.api.rewards.RewardHandler; @@ -29,8 +28,7 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { public boolean onRequirementsRequest(Reward reward, AdvancedCoreUser user, String expression, RewardOptions rewardOptions) { return expression.equals("") || new JavascriptEngine().addPlayer(user.getOfflinePlayer()) - .getBooleanValue(PlaceholderUtils.replacePlaceHolders(user.getOfflinePlayer(), - PlaceholderUtils.replacePlaceHolder(expression, rewardOptions.getPlaceholders()))); + .addPlaceholders(rewardOptions.getPlaceholders()).getBooleanValue(expression); } }.priority(90).addEditButton(new EditGUIButton(new ItemBuilder("DETECTOR_RAIL"), new EditGUIValueString("JavascriptExpression", null) { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptEngineConcurrencyTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptEngineConcurrencyTest.java new file mode 100644 index 000000000..20c75a0c0 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptEngineConcurrencyTest.java @@ -0,0 +1,79 @@ +package com.bencodez.advancedcore.api.javascript; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.Reader; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.LockSupport; + +import javax.script.AbstractScriptEngine; +import javax.script.Bindings; +import javax.script.ScriptContext; +import javax.script.ScriptEngineFactory; +import javax.script.ScriptException; +import javax.script.SimpleBindings; + +import org.junit.jupiter.api.Test; + +class JavascriptEngineConcurrencyTest { + + @Test + void sharedEngineBindingsAndEvaluationAreAtomic() throws Exception { + LockCheckingScriptEngine engine = new LockCheckingScriptEngine(); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + + try { + Future first = executor.submit(() -> { + start.await(); + return JavascriptEngine.evaluateWithBindings(engine, "value", Map.of("value", "first")); + }); + Future second = executor.submit(() -> { + start.await(); + return JavascriptEngine.evaluateWithBindings(engine, "value", Map.of("value", "second")); + }); + + start.countDown(); + assertEquals("first", first.get(2, TimeUnit.SECONDS)); + assertEquals("second", second.get(2, TimeUnit.SECONDS)); + assertTrue(engine.allEvaluationsHeldLock.get()); + } finally { + executor.shutdownNow(); + } + } + + private static final class LockCheckingScriptEngine extends AbstractScriptEngine { + private final AtomicBoolean allEvaluationsHeldLock = new AtomicBoolean(true); + + @Override + public Object eval(String script, ScriptContext context) { + if (!Thread.holdsLock(this)) { + allEvaluationsHeldLock.set(false); + } + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(25)); + return context.getAttribute(script, ScriptContext.ENGINE_SCOPE); + } + + @Override + public Object eval(Reader reader, ScriptContext context) throws ScriptException { + throw new UnsupportedOperationException(); + } + + @Override + public Bindings createBindings() { + return new SimpleBindings(); + } + + @Override + public ScriptEngineFactory getFactory() { + return null; + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderBinderTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderBinderTest.java new file mode 100644 index 000000000..b22af5a01 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderBinderTest.java @@ -0,0 +1,195 @@ +package com.bencodez.advancedcore.api.javascript; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; + +import org.junit.jupiter.api.Test; + +class JavascriptPlaceholderBinderTest { + + @Test + void barePlaceholderIsBoundAsData() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, 'op attacker')"; + + String prepared = JavascriptPlaceholderBinder.bind("%value% == true", ignored -> injection, bindings::put); + + assertEquals("__advancedCorePlaceholder0 == true", prepared); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + assertFalse(prepared.contains(injection)); + } + + @Test + void numericAndBooleanValuesKeepExpressionTypes() { + HashMap bindings = new HashMap<>(); + + String prepared = JavascriptPlaceholderBinder.bind("%count% > 1 && %allowed% == true", + token -> token.equals("%count%") ? "2.5" : "true", bindings::put); + + assertEquals("__advancedCorePlaceholder0 > 1 && __advancedCorePlaceholder1 == true", prepared); + assertEquals(2.5D, bindings.get("__advancedCorePlaceholder0")); + assertEquals(Boolean.TRUE, bindings.get("__advancedCorePlaceholder1")); + } + + @Test + void ordinaryCompactModuloExpressionIsNotTreatedAsAPlaceholder() { + HashMap bindings = new HashMap<>(); + + String prepared = JavascriptPlaceholderBinder.bind("10%3%2", token -> token, bindings::put); + + assertEquals("10%3%2", prepared); + assertTrue(bindings.isEmpty()); + } + + @Test + void unresolvedPercentTokenRemainsUntouched() { + HashMap bindings = new HashMap<>(); + + String prepared = JavascriptPlaceholderBinder.bind("'%unknown%'", token -> token, bindings::put); + + assertEquals("'%unknown%'", prepared); + assertTrue(bindings.isEmpty()); + } + + @Test + void compactModuloCanAppearBesideAResolvedPlaceholder() { + HashMap bindings = new HashMap<>(); + + String prepared = JavascriptPlaceholderBinder.bind("10%3%2 + %value%", + token -> token.equals("%value%") ? "4" : token, bindings::put); + + assertEquals("10%3%2 + __advancedCorePlaceholder0", prepared); + assertEquals(4L, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void moduloCandidateCannotHideAnOverlappingResolvedPlaceholder() { + HashMap bindings = new HashMap<>(); + + String prepared = JavascriptPlaceholderBinder.bind("10%3 == 1 && %value% == true", + token -> token.equals("%value%") ? "true" : token, bindings::put); + + assertEquals("10%3 == 1 && __advancedCorePlaceholder0 == true", prepared); + assertEquals(Boolean.TRUE, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void placeholderApiStyleTokenMayContainWhitespace() { + HashMap bindings = new HashMap<>(); + + String prepared = JavascriptPlaceholderBinder.bind("%exp_argument with spaces% == true", + ignored -> "true", bindings::put); + + assertEquals("__advancedCorePlaceholder0 == true", prepared); + assertEquals(Boolean.TRUE, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void quotedAndEmbeddedPlaceholdersStayStrings() { + HashMap bindings = new HashMap<>(); + + String prepared = JavascriptPlaceholderBinder.bind("'%code%' === '001' && 'Hello %name%!' === 'Hello Ben!'", + token -> token.equals("%code%") ? "001" : "Ben", bindings::put); + + assertEquals("'001' === '001' && 'Hello Ben!' === 'Hello Ben!'", prepared); + assertTrue(bindings.isEmpty()); + } + + @Test + void stringInjectionIsEscaped() { + HashMap bindings = new HashMap<>(); + String injection = "'; Bukkit.dispatchCommand(Console, 'op attacker'); '"; + + String prepared = JavascriptPlaceholderBinder.bind("'%name%' == 'safe'", ignored -> injection, bindings::put); + + assertEquals("'\\'; Bukkit.dispatchCommand(Console, \\'op attacker\\'); \\'' == 'safe'", prepared); + assertTrue(bindings.isEmpty()); + } + + @Test + void templateTextAndExpressionUseDifferentContexts() { + HashMap bindings = new HashMap<>(); + String prepared = JavascriptPlaceholderBinder.bind("`Hello %name% ${%count% + 1}`", + token -> token.equals("%name%") ? "${attack()}`" : "2", bindings::put); + + assertEquals("`Hello \\${attack()}\\` ${__advancedCorePlaceholder0 + 1}`", prepared); + assertEquals(2L, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void oddBackslashBeforeTemplatePlaceholderCannotReactivateInterpolation() { + HashMap bindings = new HashMap<>(); + + String prepared = JavascriptPlaceholderBinder.bind("`\\%value%`", ignored -> "${attack()}", bindings::put); + + assertEquals("`\\${attack()}`", prepared); + assertTrue(bindings.isEmpty()); + } + + @Test + void evenBackslashesBeforeTemplatePlaceholderRemainLiteralAndSafe() { + HashMap bindings = new HashMap<>(); + + String prepared = JavascriptPlaceholderBinder.bind("`\\\\%value%`", ignored -> "${attack()}", bindings::put); + + assertEquals("`\\\\\\${attack()}`", prepared); + assertTrue(bindings.isEmpty()); + } + + @Test + void oddBackslashBeforeStringPlaceholderCannotReactivateQuote() { + HashMap bindings = new HashMap<>(); + String injection = "'; attack(); '"; + + String prepared = JavascriptPlaceholderBinder.bind("'\\%value%'", ignored -> injection, bindings::put); + + assertEquals("'\\'; attack(); \\''", prepared); + assertTrue(bindings.isEmpty()); + } + + @Test + void regexPlaceholderIsEscapedAsLiteralPatternText() { + HashMap bindings = new HashMap<>(); + + String prepared = JavascriptPlaceholderBinder.bind("/^%name%$/i.test(value)", + ignored -> "Ben.*", bindings::put); + + assertEquals("/^Ben\\.\\*$/i.test(value)", prepared); + assertTrue(bindings.isEmpty()); + } + + @Test + void oddBackslashBeforeRegexPlaceholderCannotReactivateDelimiter() { + HashMap bindings = new HashMap<>(); + + String prepared = JavascriptPlaceholderBinder.bind("/\\%value%/.test(input)", + ignored -> "/; attack() //", bindings::put); + + assertEquals("/\\/; attack\\(\\) \\/\\//.test(input)", prepared); + assertTrue(bindings.isEmpty()); + } + + @Test + void placeholdersInCommentsAreNotEvaluatedOrRewritten() { + HashMap bindings = new HashMap<>(); + + String prepared = JavascriptPlaceholderBinder.bind("obj?.name; /* %name% */ true", + ignored -> "danger()", bindings::put); + + assertEquals("obj?.name; /* %name% */ true", prepared); + assertTrue(bindings.isEmpty()); + } + + @Test + void invalidPlaceholderBearingJavascriptFailsClosed() { + HashMap bindings = new HashMap<>(); + + assertThrows(IllegalArgumentException.class, + () -> JavascriptPlaceholderBinder.bind("if ( %name%", ignored -> "Ben", bindings::put)); + assertTrue(bindings.isEmpty()); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptTextTemplateTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptTextTemplateTest.java new file mode 100644 index 000000000..ea9eca6bb --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptTextTemplateTest.java @@ -0,0 +1,46 @@ +package com.bencodez.advancedcore.api.javascript; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import org.junit.jupiter.api.Test; + +class JavascriptTextTemplateTest { + + @Test + void evaluatesOnlyMarkersPresentInOriginalText() { + String result = JavascriptTextTemplate.parse("A [Javascript=1 + 1] B") + .evaluate(value -> value, script -> "2"); + + assertEquals("A 2 B", result); + } + + @Test + void generatedMarkerFromTextRemainsText() { + String result = JavascriptTextTemplate.parse("%value%") + .evaluate(value -> value.replace("%value%", "[Javascript=danger()]"), script -> "executed"); + + assertEquals("[Javascript =danger()]", result); + assertFalse(result.contains("[Javascript=")); + } + + @Test + void generatedMarkerFromJavascriptResultCannotRunOnSecondPass() { + String first = JavascriptTextTemplate.parse("[Javascript=makeText()]") + .evaluate(value -> value, script -> "[Javascript=danger()]"); + String second = JavascriptTextTemplate.parse(first) + .evaluate(value -> value, script -> "executed"); + + assertEquals("[Javascript =danger()]", first); + assertEquals(first, second); + } + + @Test + void transformPreservesAuthoredBoundary() { + String result = JavascriptTextTemplate.parse("before %value% [Javascript='%value%'] after") + .transform(value -> value.replace("%value%", "text"), + script -> script.replace("%value%", "encoded")); + + assertEquals("before text [Javascript='encoded'] after", result); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/messages/PlaceholderUtilsJavascriptBoundaryTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/messages/PlaceholderUtilsJavascriptBoundaryTest.java new file mode 100644 index 000000000..adc86acbc --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/messages/PlaceholderUtilsJavascriptBoundaryTest.java @@ -0,0 +1,47 @@ +package com.bencodez.advancedcore.api.messages; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; + +import org.junit.jupiter.api.Test; + +class PlaceholderUtilsJavascriptBoundaryTest { + + @Test + void customPlaceholderCannotCreateJavascriptMarker() { + HashMap placeholders = new HashMap<>(); + placeholders.put("value", "[Javascript=Bukkit.dispatchCommand(Console,'op attacker')]"); + + String result = PlaceholderUtils.replacePlaceHolder("prefix %value%", placeholders); + + assertFalse(result.contains("[Javascript=")); + assertEquals("prefix [Javascript =Bukkit.dispatchCommand(Console,'op attacker')]", result); + } + + @Test + void multipleSubstitutionsCannotAssembleJavascriptMarker() { + HashMap placeholders = new HashMap<>(); + placeholders.put("part1", "Java"); + placeholders.put("part2", "script"); + + String result = PlaceholderUtils.replacePlaceHolder("[%part1%%part2%=danger]", placeholders); + + assertFalse(result.contains("[Javascript=")); + assertEquals("[Javascript =danger]", result); + } + + @Test + void customValuesInsideAuthoredMarkerAreEncodedAsData() { + HashMap placeholders = new HashMap<>(); + String injection = "'; Bukkit.dispatchCommand(Console,'op attacker'); '"; + placeholders.put("value", injection); + + String result = PlaceholderUtils.replacePlaceHolder("[Javascript='%value%']", placeholders); + + assertTrue(result.startsWith("[Javascript='%__advancedcore_bound_")); + assertFalse(result.contains(injection)); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/messages/PlaceholderUtilsTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/messages/PlaceholderUtilsTest.java index f60503ed5..f5fd05e4c 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/messages/PlaceholderUtilsTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/messages/PlaceholderUtilsTest.java @@ -1,10 +1,13 @@ package com.bencodez.advancedcore.tests.messages; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; +import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; @@ -16,6 +19,26 @@ import me.clip.placeholderapi.PlaceholderAPI; public class PlaceholderUtilsTest { + @Test + public void disabledJavascriptStillExpandsPlaceholderApiInsideAuthoredMarker() { + BaseTest base = BaseTest.getInstance(); + Player player = mock(Player.class); + + when(base.options.isJavascriptEngineEnabled()).thenReturn(false); + when(base.plugin.isPlaceHolderAPIEnabled()).thenReturn(true); + + try (MockedStatic placeholderApiStatic = mockStatic(PlaceholderAPI.class)) { + placeholderApiStatic.when(() -> PlaceholderAPI.setPlaceholders(any(OfflinePlayer.class), anyString())) + .thenAnswer(invocation -> invocation.getArgument(1) + .replace("%player_name%", "Ben") + .replace("%attack%", "] [Javascript=danger()")); + + assertEquals("Hello Ben [Javascript='Ben']", + PlaceholderUtils.replaceJavascript(player, "Hello %player_name% [Javascript='%player_name%']")); + assertEquals("[Javascript='] [Javascript =danger()']", + PlaceholderUtils.replaceJavascript(player, "[Javascript='%attack%']")); + } + } @Test public void replaceJavascriptOnlyDoesNotExpandPlaceholderApiOutput() {