package org.rakam.util.javascript;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import io.airlift.log.Level;
import io.airlift.log.Logger;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.QueryStringDecoder;
import jdk.nashorn.api.scripting.ClassFilter;
import jdk.nashorn.api.scripting.NashornScriptEngineFactory;
import org.rakam.analysis.ConfigManager;
import org.rakam.collection.Event;
import org.rakam.collection.EventCollectionHttpService;
import org.rakam.collection.EventList;
import org.rakam.util.javascript.JSCodeLoggerService.LogEntry;
import org.rakam.collection.JsonEventDeserializer;
import org.rakam.plugin.EventMapper;
import org.rakam.plugin.EventStore;
import org.rakam.plugin.RAsyncHttpClient;
import org.rakam.util.CryptUtil;
import org.rakam.util.JsonHelper;
import org.rakam.util.RakamException;
import javax.annotation.Nullable;
import javax.inject.Inject;
import javax.inject.Named;
import javax.script.Bindings;
import javax.script.Invocable;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptException;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.URL;
import java.net.UnknownHostException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import static com.fasterxml.jackson.core.JsonToken.START_OBJECT;
import static io.netty.handler.codec.http.HttpResponseStatus.BAD_REQUEST;
import static org.rakam.plugin.EventMapper.RequestParams.EMPTY_PARAMS;
public class JSCodeCompiler
{
private final static Logger LOGGER = Logger.get(JSCodeCompiler.class);
private final @Named("rakam-client") RAsyncHttpClient httpClient;
private final ConfigManager configManager;
private final boolean loadAllowed;
private final InetAddress localhost;
private final LoggerFactory loggerService;
private static final NashornScriptEngineFactory factory = new NashornScriptEngineFactory();
private static final String[] args = {"-strict", "--no-syntax-extensions"};
private static final NashornEngineFilter classFilter = new NashornEngineFilter();
private static final ClassLoader classLoader = new SafeClassLoader() {};
private static Map<String, Object> JS_UTIL = ImmutableMap.of(
"crypt", new JSUtil.JSCryptUtil(),
"request", new JSUtil.JSRequestUtil());
private final boolean customEnabled;
@Inject
public JSCodeCompiler(
ConfigManager configManager,
@Named("rakam-client") RAsyncHttpClient httpClient,
JSCodeLoggerService loggerService,
JavascriptConfig config)
{
this(configManager, httpClient,
(project, prefix) -> loggerService.createLogger(project, prefix),
false, config.getCustomEnabled());
}
public JSCodeCompiler(
ConfigManager configManager,
@Named("rakam-client") RAsyncHttpClient httpClient,
LoggerFactory loggerService,
boolean loadAllowed,
boolean customEnabled)
{
this.configManager = configManager;
this.httpClient = httpClient;
this.loggerService = loggerService;
this.loadAllowed = loadAllowed;
this.customEnabled = customEnabled;
try {
localhost = InetAddress.getLocalHost();
}
catch (UnknownHostException e) {
throw Throwables.propagate(e);
}
}
public class JSEventStore
{
private final EventStore eventStore;
private final String project;
private final JsonEventDeserializer jsonEventDeserializer;
private final List<EventMapper> eventMapperSet;
public JSEventStore(String project, JsonEventDeserializer jsonEventDeserializer, EventStore eventStore, List<EventMapper> eventMapperSet)
{
this.project = project;
this.jsonEventDeserializer = jsonEventDeserializer;
this.eventStore = eventStore;
this.eventMapperSet = eventMapperSet;
}
public void store(String jsonRaw)
throws IOException
{
if (jsonEventDeserializer == null) {
throw new RakamException("Event store is not supported.", BAD_REQUEST);
}
JsonParser jp = JsonHelper.getMapper().getFactory().createParser(jsonRaw);
JsonToken t = jp.nextToken();
if (t != JsonToken.START_ARRAY) {
throw new RakamException("The script didn't return an array", BAD_REQUEST);
}
t = jp.nextToken();
List<Event> list = new ArrayList<>();
for (; t == START_OBJECT; t = jp.nextToken()) {
list.add(jsonEventDeserializer.deserializeWithProject(jp, project, Event.EventContext.empty(), true));
}
EventCollectionHttpService.mapEvent(eventMapperSet,
eventMapper -> eventMapper.mapAsync(new EventList(Event.EventContext.empty(), list),
EMPTY_PARAMS, localhost, HttpHeaders.EMPTY_HEADERS));
eventStore.storeBatch(list);
}
}
public static class NashornEngineFilter
implements ClassFilter
{
@Override
public boolean exposeToScripts(String s)
{
return false;
}
}
public Invocable createEngine(String project, String code, String prefix)
throws ScriptException
{
return createEngine(
code,
loggerService.createLogger(project, prefix),
null,
prefix == null ? new MemoryConfigManager() : createConfigManager(project, prefix));
}
public interface LoggerFactory
{
ILogger createLogger(String project, String prefix);
}
public JSConfigManager createConfigManager(String project, String prefix)
{
return new JSConfigManager(configManager, project, prefix);
}
public JSEventStore getEventStore(String project, JsonEventDeserializer jsonEventDeserializer, EventStore eventStore, List<EventMapper> eventMappers)
{
return new JSEventStore(project, jsonEventDeserializer, eventStore, eventMappers);
}
public Invocable createEngine(String code, ILogger logger, JSEventStore eventStore, IJSConfigManager configManager)
throws ScriptException
{
return createEngine(code, logger, eventStore, configManager, (scriptEngine, bindings) -> {
});
}
public Invocable createEngine(String code, ILogger logger, JSEventStore eventStore, IJSConfigManager configManager, BiConsumer<ScriptEngine, Bindings> binding)
throws ScriptException
{
if (!customEnabled) {
int firstLineBreak = code.indexOf("\n");
if(firstLineBreak == -1) {
throw new RakamException("Custom javascript code is not allowed in trial mode.", BAD_REQUEST);
}
String substring = code.substring(0, firstLineBreak);
if(!substring.startsWith("//@ sourceURL=rakam-ui/src/main/resources/")) {
throw new RakamException("Custom javascript code is not allowed in trial mode.", BAD_REQUEST);
}
String path = substring.substring("//@ sourceURL=rakam-ui/src/main/resources/".length());
}
ScriptEngine engine = factory.getScriptEngine(args, classLoader, classFilter);
Bindings bindings = engine.getBindings(ScriptContext.ENGINE_SCOPE);
bindings.remove("print");
if (!loadAllowed) {
bindings.remove("load");
}
bindings.remove("loadWithNewGlobal");
bindings.remove("exit");
bindings.remove("Java");
bindings.remove("readFully");
bindings.remove("readLine");
bindings.remove("print");
bindings.remove("echo");
bindings.remove("quit");
bindings.put("logger", logger);
bindings.put("util", JS_UTIL);
bindings.put("config", configManager);
if (eventStore != null) {
bindings.put("$$eventStore", eventStore);
engine.eval("var eventStore = {store: function(call) { $$eventStore.store(JSON.stringify(call)); }}");
}
bindings.put("http", httpClient);
engine.eval(code);
binding.accept(engine, bindings);
return (Invocable) engine;
}
public static class TestLogger
implements ILogger
{
List<LogEntry> entries = new ArrayList();
public List<LogEntry> getEntries()
{
return ImmutableList.copyOf(entries);
}
@Override
public void debug(String value)
{
entries.add(new LogEntry("", Level.DEBUG, value, Instant.now()));
}
@Override
public void warn(String value)
{
entries.add(new LogEntry("", Level.WARN, value, Instant.now()));
}
@Override
public void info(String value)
{
entries.add(new LogEntry("", Level.INFO, value, Instant.now()));
}
@Override
public void error(String value)
{
entries.add(new LogEntry("", Level.ERROR, value, Instant.now()));
}
}
public static class JavaLogger
implements ILogger
{
private final String prefix;
private final String project;
public JavaLogger(String project, String prefix)
{
this.prefix = prefix;
this.project = project;
}
@Override
public void debug(String value)
{
LOGGER.debug("Script(" + project + ", " + prefix + ")" + value);
}
@Override
public void warn(String value)
{
LOGGER.warn("Script(" + project + ", " + prefix + ")" + value);
}
@Override
public void info(String value)
{
LOGGER.info("Script(" + project + ", " + prefix + ")" + value);
}
@Override
public void error(String value)
{
LOGGER.error("Script(" + project + ", " + prefix + ")" + value);
}
}
public interface IJSConfigManager
{
Object get(String configName);
void set(String configName, Object value);
Object setOnce(String configName, Object value);
}
public static class MemoryConfigManager
implements IJSConfigManager
{
Map<String, Object> configs = new HashMap<>();
@Override
public Object get(String configName)
{
return configs.get(configName);
}
@Override
public void set(String configName, Object value)
{
configs.put(configName, value);
}
@Override
public Object setOnce(String configName, Object value)
{
return configs.computeIfAbsent(configName, (k) -> value);
}
}
private static class JSUtil
{
private JSUtil()
{
}
public static class JSRequestUtil
{
public Map<String, List<String>> parseFormData(String data)
{
return new QueryStringDecoder("?" + data).parameters();
}
}
public static class JSCryptUtil
{
public String generateRandomKey(int length)
{
return CryptUtil.generateRandomKey(length);
}
public String sha1(String value)
{
return CryptUtil.sha1(value);
}
public String encryptWithHMacSHA1(String data, String secret)
{
return CryptUtil.encryptWithHMacSHA1(data, secret);
}
public String encryptToHex(String data, String secret, String hashType)
{
return CryptUtil.encryptToHex(data, secret, hashType);
}
public String encryptAES(String data, String secretKey)
{
return CryptUtil.encryptAES(data, secretKey);
}
public String decryptAES(String data, String secretKey)
{
return CryptUtil.decryptAES(data, secretKey);
}
}
}
public static class SafeClassLoader
extends ClassLoader
{
@Override
public Class<?> loadClass(String name)
throws ClassNotFoundException
{
throw new UnsupportedOperationException();
}
@Override
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
return super.loadClass(name, resolve);
}
@Override
protected Object getClassLoadingLock(String className)
{
return super.getClassLoadingLock(className);
}
@Override
public void clearAssertionStatus()
{
throw new UnsupportedOperationException();
}
@Override
protected Class<?> findClass(String name)
throws ClassNotFoundException
{
throw new UnsupportedOperationException();
}
@Nullable
@Override
public URL getResource(String name)
{
throw new UnsupportedOperationException();
}
@Override
public Enumeration<URL> getResources(String name)
throws IOException
{
throw new UnsupportedOperationException();
}
@Override
protected URL findResource(String name)
{
throw new UnsupportedOperationException();
}
@Override
protected Enumeration<URL> findResources(String name)
throws IOException
{
throw new UnsupportedOperationException();
}
@Override
public InputStream getResourceAsStream(String name)
{
throw new UnsupportedOperationException();
}
@Override
protected Package definePackage(String name, String specTitle, String specVersion, String specVendor, String implTitle, String implVersion, String implVendor, URL sealBase)
throws IllegalArgumentException
{
throw new UnsupportedOperationException();
}
@Override
protected Package getPackage(String name)
{
throw new UnsupportedOperationException();
}
@Override
protected Package[] getPackages()
{
throw new UnsupportedOperationException();
}
@Override
protected String findLibrary(String libname)
{
throw new UnsupportedOperationException();
}
@Override
public void setDefaultAssertionStatus(boolean enabled)
{
throw new UnsupportedOperationException();
}
@Override
public void setPackageAssertionStatus(String packageName, boolean enabled)
{
throw new UnsupportedOperationException();
}
@Override
public void setClassAssertionStatus(String className, boolean enabled)
{
throw new UnsupportedOperationException();
}
}
}