diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 665fba86ab1..f1eeb220b17 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -26,4 +26,5 @@ /jans-linux-setup/ @mbaser @smansoft @yuriyz /jans-linux-setup/jans_setup/setup_app/version.py @moabu /jans-linux-setup/static/scripts/admin_ui_plugin.py @mbaser @duttarnab -/super-jans @harsukhbir \ No newline at end of file +/super-jans @harsukhbir +/agama/ @jgomer2001 \ No newline at end of file diff --git a/.github/workflows/central_code_quality_check.yml b/.github/workflows/central_code_quality_check.yml index ede51beb702..51cff582a96 100644 --- a/.github/workflows/central_code_quality_check.yml +++ b/.github/workflows/central_code_quality_check.yml @@ -49,6 +49,7 @@ jobs: jans-notify jans-fido2 jans-eleven + agama NON_JVM_PROJECTS: | jans-linux-setup jans-cli diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c4e53b26560..ef3f6e4655a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -83,7 +83,7 @@ jobs: #max-parallel: 1 fail-fast: false matrix: - maven: [ "jans-scim", "jans-orm", "jans-notify", "jans-fido2", "jans-eleven", "jans-core", "jans-config-api", "jans-client-api", "jans-bom", "jans-auth-server" ] + maven: [ "jans-scim", "jans-orm", "jans-notify", "jans-fido2", "jans-eleven", "jans-core", "jans-config-api", "jans-client-api", "jans-bom", "jans-auth-server", "agama" ] steps: - name: Checkout uses: actions/checkout@v3 diff --git a/agama/engine/pom.xml b/agama/engine/pom.xml new file mode 100644 index 00000000000..5bf40a7ff6f --- /dev/null +++ b/agama/engine/pom.xml @@ -0,0 +1,192 @@ + + + + 4.0.0 + + agama-engine + jar + + + io.jans + agama + 1.0.0-SNAPSHOT + + + + UTF-8 + 11 + 11 + + + + + jans + Jans repository + https://maven.jans.io/maven + + + + + + + + + io.jans + agama-model + 1.0.0-SNAPSHOT + + + io.jans + agama-transpiler + 1.0.0-SNAPSHOT + + + + + jakarta.servlet + jakarta.servlet-api + provided + + + + + jakarta.faces + jakarta.faces-api + provided + + + + + org.jboss.spec.javax.ws.rs + jboss-jaxrs-api_3.0_spec + provided + + + + + org.jboss.weld.servlet + weld-servlet-shaded + provided + + + + + org.freemarker + freemarker + 2.3.31 + + + + + org.apache.logging.log4j + log4j-slf4j-impl + provided + + + org.apache.logging.log4j + log4j-api + provided + + + org.apache.logging.log4j + log4j-core + provided + + + + + com.fasterxml.jackson.core + jackson-databind + provided + + + com.fasterxml.jackson.core + jackson-core + provided + + + com.fasterxml.jackson.core + jackson-annotations + provided + + + + + org.mozilla + rhino + 1.7.14 + + + + + org.codehaus.groovy + groovy + 3.0.7 + + + + + io.jans + jans-core-util + provided + + + io.jans + jans-core-cdi + provided + + + io.jans + jans-core-service + provided + + + io.jans + jans-orm-core + provided + + + io.jans + jans-auth-model + provided + + + + commons-codec + commons-codec + provided + + + + com.esotericsoftware + kryo + 5.3.0 + + + + + diff --git a/agama/engine/src/main/java/io/jans/agama/NativeJansFlowBridge.java b/agama/engine/src/main/java/io/jans/agama/NativeJansFlowBridge.java new file mode 100644 index 00000000000..03972cfa3b2 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/NativeJansFlowBridge.java @@ -0,0 +1,87 @@ +package io.jans.agama; + +import io.jans.agama.engine.model.FlowResult; +import io.jans.agama.engine.model.FlowStatus; +import io.jans.agama.engine.service.AgamaPersistenceService; +import io.jans.agama.engine.service.FlowService; +import io.jans.agama.engine.service.WebContext; +import io.jans.agama.engine.servlet.ExecutionServlet; +import io.jans.agama.model.EngineConfig; + +import jakarta.inject.Inject; +import jakarta.enterprise.context.RequestScoped; +import java.io.IOException; + +import org.slf4j.Logger; + +@RequestScoped +public class NativeJansFlowBridge { + + @Inject + private Logger logger; + + @Inject + private AgamaPersistenceService aps; + + @Inject + private FlowService fs; + + @Inject + private EngineConfig conf; + + @Inject + private WebContext webContext; + + public String scriptPageUrl() { + return conf.getBridgeScriptPage(); + } + + public String getTriggerUrl() { + return webContext.getContextPath() + ExecutionServlet.URL_PREFIX + + "agama" + ExecutionServlet.URL_SUFFIX; + } + + public Boolean prepareFlow(String sessionId, String qname, String jsonInput) throws Exception { + + logger.info("Preparing flow '{}'", qname); + Boolean alreadyRunning = null; + if (fs.isEnabled(qname)) { + + FlowStatus st = aps.getFlowStatus(sessionId); + alreadyRunning = st != null; + + if (alreadyRunning && !st.getQname().equals(qname)) { + logger.warn("Flow {} is already running. Will be terminated", st.getQname()); + fs.terminateFlow(); + st = null; + } + if (st == null) { + st = new FlowStatus(); + st.setStartedAt(FlowStatus.PREPARED); + st.setQname(qname); + st.setJsonInput(jsonInput); + aps.createFlowRun(sessionId, st, System.currentTimeMillis() + 1000*conf.getInterruptionTime()); + } + } + return alreadyRunning; + + } + + public FlowResult close() throws IOException { + + FlowStatus st = fs.getRunningFlowStatus(); + if (st == null) { + logger.error("No current flow running"); + + } else if (st.getStartedAt() != FlowStatus.FINISHED) { + logger.error("Current flow hasn't finished"); + + } else { + fs.terminateFlow(); + return st.getResult(); + } + return null; + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/continuation/PendingException.java b/agama/engine/src/main/java/io/jans/agama/engine/continuation/PendingException.java new file mode 100644 index 00000000000..ffbbb1fd775 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/continuation/PendingException.java @@ -0,0 +1,27 @@ +package io.jans.agama.engine.continuation; + +import org.mozilla.javascript.ContinuationPending; +import org.mozilla.javascript.NativeContinuation; + +public class PendingException extends ContinuationPending { + + private boolean allowCallbackResume; + + public PendingException(NativeContinuation continuation) { + super(continuation); + } + + @Override + public NativeContinuation getContinuation() { + return (NativeContinuation) super.getContinuation(); + } + + public boolean isAllowCallbackResume() { + return allowCallbackResume; + } + + public void setAllowCallbackResume(boolean allowCallbackResume) { + this.allowCallbackResume = allowCallbackResume; + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/continuation/PendingRedirectException.java b/agama/engine/src/main/java/io/jans/agama/engine/continuation/PendingRedirectException.java new file mode 100644 index 00000000000..490f62b88a5 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/continuation/PendingRedirectException.java @@ -0,0 +1,21 @@ +package io.jans.agama.engine.continuation; + +import org.mozilla.javascript.NativeContinuation; + +public class PendingRedirectException extends PendingException { + + public PendingRedirectException(NativeContinuation continuation) { + super(continuation); + } + + private String location; + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/continuation/PendingRenderException.java b/agama/engine/src/main/java/io/jans/agama/engine/continuation/PendingRenderException.java new file mode 100644 index 00000000000..d615338f7db --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/continuation/PendingRenderException.java @@ -0,0 +1,32 @@ +package io.jans.agama.engine.continuation; + +import java.util.Map; + +import org.mozilla.javascript.NativeContinuation; + +public class PendingRenderException extends PendingException { + + private String templatePath; + private Map dataModel; + + public PendingRenderException(NativeContinuation continuation) { + super(continuation); + } + + public String getTemplatePath() { + return templatePath; + } + + public void setTemplatePath(String templatePath) { + this.templatePath = templatePath; + } + + public Map getDataModel() { + return dataModel; + } + + public void setDataModel(Map dataModel) { + this.dataModel = dataModel; + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/exception/FlowCrashException.java b/agama/engine/src/main/java/io/jans/agama/engine/exception/FlowCrashException.java new file mode 100644 index 00000000000..b476192080b --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/exception/FlowCrashException.java @@ -0,0 +1,13 @@ +package io.jans.agama.engine.exception; + +public class FlowCrashException extends Exception { + + public FlowCrashException(String message) { + super(message); + } + + public FlowCrashException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/exception/FlowTimeoutException.java b/agama/engine/src/main/java/io/jans/agama/engine/exception/FlowTimeoutException.java new file mode 100644 index 00000000000..857a9353a7d --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/exception/FlowTimeoutException.java @@ -0,0 +1,16 @@ +package io.jans.agama.engine.exception; + +public class FlowTimeoutException extends Exception { + + private String qname; + + public FlowTimeoutException(String message, String flowQname) { + super(message); + qname = flowQname; + } + + public String getQname() { + return qname; + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/exception/TemplateProcessingException.java b/agama/engine/src/main/java/io/jans/agama/engine/exception/TemplateProcessingException.java new file mode 100644 index 00000000000..f80dec168ca --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/exception/TemplateProcessingException.java @@ -0,0 +1,13 @@ +package io.jans.agama.engine.exception; + +public class TemplateProcessingException extends Exception { + + public TemplateProcessingException(String message) { + super(message); + } + + public TemplateProcessingException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/misc/FlowUtils.java b/agama/engine/src/main/java/io/jans/agama/engine/misc/FlowUtils.java new file mode 100644 index 00000000000..1d6075e610b --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/misc/FlowUtils.java @@ -0,0 +1,84 @@ +package io.jans.agama.engine.misc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.io.IOException; +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.commons.codec.digest.HmacAlgorithms; +import org.apache.commons.codec.digest.HmacUtils; +import org.mozilla.javascript.Scriptable; +import org.slf4j.Logger; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@ApplicationScoped +public class FlowUtils { + + private static final Path SALT_PATH = Paths.get("/etc/jans/conf/salt"); + private static final HmacAlgorithms HASH_ALG = HmacAlgorithms.HMAC_SHA_512; + + @Inject + private Logger logger; + + @Inject + private ObjectMapper mapper; + + /** + * It is assumed that values in the map are String arrays with at least one element + * @param map + * @return + * @throws JsonProcessingException + */ + public String toJsonString(Map map) throws JsonProcessingException { + + Map result = new HashMap<>(); + if (map != null) { + + for(String key : map.keySet()) { + String[] list = map.get(key); + result.put(key, list.length == 1 ? list[0] : Arrays.asList(list)); + } + } + + //TODO: implement a smarter serialization? example: + // if key starts with prefix i: try to convert to int, b: for boolean, m: map, etc. + return mapper.writeValueAsString(result); + } + + public void printScopeIds(Scriptable scope) { + List scopeIds = Stream.of(scope.getIds()).map(Object::toString).collect(Collectors.toList()); + logger.trace("Global scope has {} ids: {}", scopeIds.size(), scopeIds); + } + + public String hash(String message) throws IOException { + return new HmacUtils(HASH_ALG, sharedKey()).hmacHex(message); + } + + public String hash(byte[] message) throws IOException { + return new HmacUtils(HASH_ALG, sharedKey()).hmacHex(message); + } + + private String sharedKey() throws IOException { + + //I preferred not to have file contents in memory (static var) + Properties p = new Properties(); + p.load(new StringReader(Files.readString(SALT_PATH, UTF_8))); + return p.getProperty("encodeSalt"); + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/misc/PrimitiveUtils.java b/agama/engine/src/main/java/io/jans/agama/engine/misc/PrimitiveUtils.java new file mode 100644 index 00000000000..10d44141780 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/misc/PrimitiveUtils.java @@ -0,0 +1,90 @@ +package io.jans.agama.engine.misc; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public class PrimitiveUtils { + + enum Primitive { + CHARACTER(Character.class, Character.TYPE), + BOOLEAN(Boolean.class, Boolean.TYPE), + BYTE(Byte.class, Byte.TYPE), + DOUBLE(Double.class, Double.TYPE), + FLOAT(Float.class, Float.TYPE), + INTEGER(Integer.class, Integer.TYPE), + LONG(Long.class, Long.TYPE), + SHORT(Short.class, Short.TYPE); + + private final Class wrapperCls; + private final Class primitiveCls; + + private final static Map, Primitive> mapW = Arrays.stream(values()) + .collect(Collectors.toMap(p -> p.wrapperCls, p -> p)); + + private final static Map, Primitive> mapP = Arrays.stream(values()) + .collect(Collectors.toMap(p -> p.primitiveCls, p -> p)); + + Primitive(Class wrapperCls, Class primitiveCls) { + this.wrapperCls = wrapperCls; + this.primitiveCls = primitiveCls; + } + + private static Primitive from(Map, Primitive> map, Class cls) { + return map.get(cls); + } + + static Primitive fromWrapperClass(Class cls) { + return from(mapW, cls); + } + + static Primitive fromPrimitiveClass(Class cls) { + return from(mapP, cls); + } + + static Primitive fromWrapperOrPrimitiveClass(Class cls) { + return Optional.ofNullable(fromWrapperClass(cls)).orElse(fromPrimitiveClass(cls)); + } + + } + + public static Boolean compatible(Class argumentCls, Class paramType) { + + Primitive p = Primitive.fromWrapperClass(argumentCls); + if (p != null) { + if (argumentCls.equals(paramType)) return true; + + return p.equals(Primitive.fromPrimitiveClass(paramType)); + } + return null; + } + + public static boolean isPrimitive(Class cls, boolean wrapperCounts) { + Primitive p = wrapperCounts ? Primitive.fromWrapperOrPrimitiveClass(cls) : + Primitive.fromPrimitiveClass(cls); + return p != null; + } + + public static Object primitiveNumberFrom(Double value, Class destination) { + + Primitive prim = Primitive.fromWrapperOrPrimitiveClass(destination); + if (prim != null) { + switch (prim) { + case BYTE: + return value.byteValue(); + case FLOAT: + return value.floatValue(); + case INTEGER: + return value.intValue(); + case LONG: + return value.longValue(); + case SHORT: + return value.shortValue(); + } + } + return null; + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/model/FlowResult.java b/agama/engine/src/main/java/io/jans/agama/engine/model/FlowResult.java new file mode 100644 index 00000000000..d3a76cdf2fd --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/model/FlowResult.java @@ -0,0 +1,44 @@ +package io.jans.agama.engine.model; + +import java.util.Map; + +public class FlowResult { + + private boolean aborted; + private boolean success; + private String error; + private Map data; + + public boolean isAborted() { + return aborted; + } + + public void setAborted(boolean aborted) { + this.aborted = aborted; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public Map getData() { + return data; + } + + public void setData(Map data) { + this.data = data; + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/model/FlowRun.java b/agama/engine/src/main/java/io/jans/agama/engine/model/FlowRun.java new file mode 100644 index 00000000000..0b508358238 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/model/FlowRun.java @@ -0,0 +1,67 @@ +package io.jans.agama.engine.model; + +import io.jans.orm.annotation.AttributeName; +import io.jans.orm.annotation.DataEntry; +import io.jans.orm.annotation.ObjectClass; + +import java.util.Date; + +@DataEntry +@ObjectClass(value = FlowRun.ATTR_NAMES.OBJECT_CLASS) +public class FlowRun extends ProtoFlowRun { + + //An enum cannot be used because elements of annotations (like AttributeName) have to be constants + public class ATTR_NAMES { + public static final String OBJECT_CLASS = "agmFlowRun"; + public static final String ID = "jansId"; + public static final String STATUS = "agFlowSt"; + } + + @AttributeName(name = "agFlowEncCont") + private String encodedContinuation; + + @AttributeName(name = "jansCustomMessage") + private String hash; + + @AttributeName(name = "exp") + private Date deletableAt; + +/* + TODO: https://github.com/JanssenProject/jans/issues/1252 + When fixed, AgamaPersistenceService#saveState and getContinuation will need refactoring + @AttributeName(name = "agFlowCont") + private byte[] continuation; + + public byte[] getContinuation() { + return continuation; + } + + public void setContinuation(byte[] continuation) { + this.continuation = continuation; + } +*/ + public String getEncodedContinuation() { + return encodedContinuation; + } + + public void setEncodedContinuation(String encodedContinuation) { + this.encodedContinuation = encodedContinuation; + } + + public String getHash() { + return hash; + } + + public void setHash(String hash) { + this.hash = hash; + } + + public Date getDeletableAt() { + return deletableAt; + } + + public void setDeletableAt(Date deletableAt) { + this.deletableAt = deletableAt; + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/model/FlowStatus.java b/agama/engine/src/main/java/io/jans/agama/engine/model/FlowStatus.java new file mode 100644 index 00000000000..f2bbd941126 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/model/FlowStatus.java @@ -0,0 +1,96 @@ +package io.jans.agama.engine.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.LinkedList; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class FlowStatus { + + public static final long PREPARED = 0; + public static final long FINISHED = -1; + + private String qname; + private String templatePath; + private long startedAt; + private Object templateDataModel; + private LinkedList parentsData = new LinkedList<>(); + private String externalRedirectUrl; + private boolean allowCallbackResume; + private String jsonInput; + + private FlowResult result; + + public Object getTemplateDataModel() { + return templateDataModel; + } + + public void setTemplateDataModel(Object templateDataModel) { + this.templateDataModel = templateDataModel; + } + + public FlowResult getResult() { + return result; + } + + public void setResult(FlowResult result) { + this.result = result; + } + + public long getStartedAt() { + return startedAt; + } + + public void setStartedAt(long startedAt) { + this.startedAt = startedAt; + } + + public String getQname() { + return qname; + } + + public void setQname(String qname) { + this.qname = qname; + } + + public String getTemplatePath() { + return templatePath; + } + + public void setTemplatePath(String templatePath) { + this.templatePath = templatePath; + } + + public String getExternalRedirectUrl() { + return externalRedirectUrl; + } + + public void setExternalRedirectUrl(String externalRedirectUrl) { + this.externalRedirectUrl = externalRedirectUrl; + } + + public boolean isAllowCallbackResume() { + return allowCallbackResume; + } + + public void setAllowCallbackResume(boolean allowCallbackResume) { + this.allowCallbackResume = allowCallbackResume; + } + + public LinkedList getParentsData() { + return parentsData; + } + + public void setParentsData(LinkedList parentsData) { + this.parentsData = parentsData; + } + + public String getJsonInput() { + return jsonInput; + } + + public void setJsonInput(String jsonInput) { + this.jsonInput = jsonInput; + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/model/ParentFlowData.java b/agama/engine/src/main/java/io/jans/agama/engine/model/ParentFlowData.java new file mode 100644 index 00000000000..371da35e8d4 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/model/ParentFlowData.java @@ -0,0 +1,27 @@ +package io.jans.agama.engine.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class ParentFlowData { + + private String parentBasepath; + private String[] pathOverrides; + + public String getParentBasepath() { + return parentBasepath; + } + + public void setParentBasepath(String parentBasepath) { + this.parentBasepath = parentBasepath; + } + + public String[] getPathOverrides() { + return pathOverrides; + } + + public void setPathOverrides(String[] pathOverrides) { + this.pathOverrides = pathOverrides; + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/model/ProtoFlowRun.java b/agama/engine/src/main/java/io/jans/agama/engine/model/ProtoFlowRun.java new file mode 100644 index 00000000000..3f1c6160cec --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/model/ProtoFlowRun.java @@ -0,0 +1,41 @@ +package io.jans.agama.engine.model; + +/** + * This class is used as a vehicle to overcome jans-orm limitation related to data + * destruction when an update is made on a partially retrieved entity. It also helps + * to make "lighter" retrievals of FlowRuns + */ +import io.jans.orm.annotation.AttributeName; +import io.jans.orm.annotation.DataEntry; +import io.jans.orm.annotation.JsonObject; +import io.jans.orm.annotation.ObjectClass; +import io.jans.orm.model.base.Entry; + +@DataEntry +@ObjectClass(value = FlowRun.ATTR_NAMES.OBJECT_CLASS) +public class ProtoFlowRun extends Entry { + + @AttributeName(name = FlowRun.ATTR_NAMES.ID) + private String id; + + @JsonObject + @AttributeName(name = FlowRun.ATTR_NAMES.STATUS) + private FlowStatus status; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public FlowStatus getStatus() { + return status; + } + + public void setStatus(FlowStatus status) { + this.status = status; + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/page/BasicTemplateModel.java b/agama/engine/src/main/java/io/jans/agama/engine/page/BasicTemplateModel.java new file mode 100644 index 00000000000..b0d2b5870e1 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/page/BasicTemplateModel.java @@ -0,0 +1,25 @@ +package io.jans.agama.engine.page; + +public class BasicTemplateModel { + + private String message; + private String flowName; + + public BasicTemplateModel(String message) { + this.message = message; + } + + public BasicTemplateModel(String message, String flowName) { + this.message = message; + this.flowName = flowName; + } + + public String getMessage() { + return message; + } + + public String getFlowName() { + return flowName; + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/page/Labels.java b/agama/engine/src/main/java/io/jans/agama/engine/page/Labels.java new file mode 100644 index 00000000000..9bf83e80e2e --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/page/Labels.java @@ -0,0 +1,59 @@ +package io.jans.agama.engine.page; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.faces.FactoryFinder; +import jakarta.faces.application.Application; +import jakarta.faces.application.ApplicationFactory; +import jakarta.faces.context.FacesContext; +import jakarta.inject.Inject; +import java.util.AbstractMap; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.ResourceBundle; +import java.util.Set; + +import org.slf4j.Logger; + +//This is not a real Map but pretends to look like one so in Freemarker templates expressions of +//the form msgs.KEY can be used. A HashMap would have led to more concise code but Weld complains +//at startup due to the presence of a final method in it. This leads to proxying issues +@ApplicationScoped +public class Labels extends AbstractMap { + + public static final String BUNDLE_ID = "msgs"; + + @Inject + private Logger logger; + + private Application facesApp; + + @Override + public Set> entrySet() { + return Collections.emptySet(); + } + + @Override + public String get(Object key) { + + try { + /*FacesContext ctx = FacesContext.getCurrentInstance(); + ResourceBundle bundle = ctx.getApplication().getResourceBundle(ctx, BUNDLE_ID);*/ + ResourceBundle bundle = facesApp.getResourceBundle(FacesContext.getCurrentInstance(), BUNDLE_ID); + return bundle.getString(key.toString()); + } catch (Exception e) { + logger.error("Failed to lookup resource bundle by key '{}': {}", key.toString(), e.getMessage()); + return null; + } + + } + + @PostConstruct + private void init() { + ApplicationFactory factory = (ApplicationFactory) FactoryFinder.getFactory(FactoryFinder.APPLICATION_FACTORY); + facesApp = factory.getApplication(); + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/page/Page.java b/agama/engine/src/main/java/io/jans/agama/engine/page/Page.java new file mode 100644 index 00000000000..08b9a7b36c5 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/page/Page.java @@ -0,0 +1,85 @@ +package io.jans.agama.engine.page; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.HashMap; +import java.util.Map; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; + +import io.jans.agama.engine.service.WebContext; + +@RequestScoped +public class Page { + + private static final String WEB_CTX_KEY = "webCtx"; + + @Inject + private WebContext webContext; + + @Inject + private ObjectMapper mapper; + + @Inject + private Labels labels; + + private String templatePath; + private Map dataModel; + private Object rawModel; + + public String getTemplatePath() { + return templatePath; + } + + public void setTemplatePath(String templatePath) { + this.templatePath = templatePath; + } + + public Object getDataModel() { + + if (rawModel == null) { + if (dataModel != null) { + dataModel.putIfAbsent(WEB_CTX_KEY, webContext); + dataModel.putIfAbsent(Labels.BUNDLE_ID, labels); + return dataModel; + } else return new Object(); + } else return rawModel; + + } + + /** + * This call is cheaper than setDataModel, but pages won't have access to any + * contextual data + * @param object + */ + public void setRawDataModel(Object object) { + rawModel = object; + dataModel = null; + } + + public void setDataModel(Object object) { + rawModel = null; + dataModel = mapFromObject(object); + } + + public void appendToDataModel(Object object) { + if (rawModel != null) { + rawModel = null; + dataModel = new HashMap<>(); + } + dataModel.putAll(mapFromObject(object)); + } + + private Map mapFromObject(Object object) { + return mapper.convertValue(object, new TypeReference>(){}); + } + + @PostConstruct + private void init() { + dataModel = new HashMap<>(); + labels = new Labels(); + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/script/LogUtils.java b/agama/engine/src/main/java/io/jans/agama/engine/script/LogUtils.java new file mode 100644 index 00000000000..ba8a7239fde --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/script/LogUtils.java @@ -0,0 +1,238 @@ +package io.jans.agama.engine.script; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.Array; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import io.jans.agama.model.EngineConfig; +import io.jans.util.Pair; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LogUtils { + + private static final Logger LOG = LoggerFactory.getLogger(LogUtils.class); + //MUST be a single character string + private static final String PLACEHOLDER = "%"; + + private static final int MAX_ITERABLE_ITEMS = ScriptUtils.managedBean(EngineConfig.class) + .getMaxItemsLoggedInCollections(); + + private enum LogLevel { + ERROR, WARN, INFO, DEBUG, TRACE; + + String getValue() { + return toString().toLowerCase(); + } + + } + + /** + * rest has at least 1 element + * @param rest + */ + public static void log(Object ...rest) { + + LogLevel level; + int dummyArgs = 0; + String sfirst; + int nargs = rest.length - 1; + + Object first = rest[0]; + if (first != null && first instanceof String) { + Pair p = getLogLevel(first.toString()); + level = p.getFirst(); + + if (ignoreLogStatement(level)) return; + + Pair q = getFormatString(p.getSecond(), nargs); + sfirst = q.getFirst(); + dummyArgs = q.getSecond(); + + } else { + level = LogLevel.INFO; + + if (ignoreLogStatement(level)) return; + + sfirst = asString(first) + getFormatString("", nargs).getFirst(); + } + + Object[] args = new String[nargs + dummyArgs]; + for (int i = 0; i < nargs; i++) { + args[i] = asString(rest[i + 1]); + } + Arrays.fill(args, nargs, args.length, ""); + String result = String.format(sfirst, args); + + switch (level) { + case ERROR: + LOG.error(result); + break; + case WARN: + LOG.warn(result); + break; + case INFO: + LOG.info(result); + break; + case DEBUG: + LOG.debug(result); + break; + case TRACE: + LOG.trace(result); + break; + } + + } + + private static boolean ignoreLogStatement(LogLevel logLevel) { + + switch (logLevel) { + case TRACE: return !LOG.isTraceEnabled(); + case DEBUG: return !LOG.isDebugEnabled(); + case INFO: return !LOG.isInfoEnabled(); + case WARN: return !LOG.isWarnEnabled(); + case ERROR: return !LOG.isErrorEnabled(); + } + return false; + + } + + private static Pair getLogLevel(String first) { + + LogLevel level = null; + String newFirst = null; + + String suffix = " "; + if (first.startsWith("@")) { + level = Stream.of(LogLevel.values()).filter( + l -> { + String lev = l.getValue(); + return first.startsWith("@" + lev.substring(0, 1) + suffix) + || first.startsWith("@" + lev + suffix); + } + ).findFirst().orElse(null); + + if (level != null) { + int levLen = first.substring(2).startsWith(suffix) ? 1 : level.getValue().length(); + newFirst = first.substring(1 + levLen + suffix.length()); + } + } + + if (level == null) { + newFirst = first; + level = LogLevel.INFO; + } + return new Pair<>(level, newFirst); + + } + + private static Pair getFormatString(String str, int nargs) { + + Integer dummyArgs = 0; + String tmp = str.replace(PLACEHOLDER, "%s"); + int existingPlaceHolders = tmp.length() - str.length(); + + if (existingPlaceHolders > 0) { + int excess = existingPlaceHolders - nargs; + if (excess < 0) { + tmp += " %s".repeat(-excess); + } else { + dummyArgs = excess; + } + } else { + tmp = str + " %s".repeat(nargs); + } + return new Pair<>(tmp, dummyArgs); + + } + + private static String subCollectionAsString(Collection col) { + + int len = col.size(); + int count = Math.min(len, MAX_ITERABLE_ITEMS); + Iterator iterator = col.iterator(); + StringBuilder sb = new StringBuilder("["); + + if (count > 0) { + for (int i = 0; i < count; i++) { + sb.append(asString(iterator.next())).append(", "); + } + if (len > count) { + sb.append("...").append(len - count).append(" more"); + } else { + sb.deleteCharAt(sb.length() - 1); + sb.deleteCharAt(sb.length() - 1); + } + } + return sb.append("]").toString(); + + } + + private static String asString(Object obj) { + + if (obj == null) return "null"; + Class objCls = obj.getClass(); + + //JS-native numeric values always come as doubles; make them look like integers if that's the case + if (objCls.equals(Double.class)) { + Double d = (Double) obj; + if (Math.floor(d) == d && d >= 1.0*Long.MIN_VALUE && d <= 1.0*Long.MAX_VALUE) { + return Long.toString(d.longValue()); + } + } else if (objCls.isArray()) { + + List list = new ArrayList<>(); + for (int i = 0; i <= MAX_ITERABLE_ITEMS; i++) { //Allows one extra element + try { + list.add(Array.get(obj, i)); + } catch (ArrayIndexOutOfBoundsException e) { + break; + } + } + return subCollectionAsString(list); + + } else if (Collection.class.isInstance(obj)) { + return subCollectionAsString((Collection) obj); + + } else if (Map.class.isInstance(obj)) { + + Map map = (Map) obj; + List entries = new ArrayList<>(); + int i = 0; + + for (Object key : map.keySet()) { + entries.add(new AbstractMap.SimpleImmutableEntry(key, map.get(key))); + if (++i > MAX_ITERABLE_ITEMS) break; //Allows one extra element + } + return subCollectionAsString(entries); + + } else if (Map.Entry.class.isInstance(obj)) { + Map.Entry e = (Map.Entry) obj; + return String.format("(%s: %s)", asString(e.getKey()), asString(e.getValue())); + + } else if (Throwable.class.isInstance(obj)) { + Throwable t = (Throwable) obj; + try( + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw)) { + + t.printStackTrace(pw); + return sw.toString(); + } catch(IOException e) { + //can be ignored + } + } + return obj.toString(); + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/script/ScriptUtils.java b/agama/engine/src/main/java/io/jans/agama/engine/script/ScriptUtils.java new file mode 100644 index 00000000000..a5d8ef68a6a --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/script/ScriptUtils.java @@ -0,0 +1,142 @@ +package io.jans.agama.engine.script; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import jakarta.enterprise.inject.spi.CDI; + +import io.jans.agama.engine.continuation.PendingRedirectException; +import io.jans.agama.engine.continuation.PendingRenderException; +import io.jans.agama.engine.misc.PrimitiveUtils; +import io.jans.agama.engine.service.ActionService; +import io.jans.agama.engine.service.FlowService; +import io.jans.util.Pair; +import org.mozilla.javascript.Context; +import org.mozilla.javascript.Function; +import org.mozilla.javascript.NativeContinuation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ScriptUtils { + + private static final Logger LOG = LoggerFactory.getLogger(ScriptUtils.class); + + // NOTE: do not alter this method's signature so that it returns void. The returned + // value is simulated when the continuation is resumed: see 3rd parameter in call + // to resumeContinuation (FlowService) + public static Pair pauseForRender(String page, boolean allowCallbackResume, Object data) + throws PendingRenderException { + + Context cx = Context.enter(); + try { + PendingRenderException pending = new PendingRenderException( + (NativeContinuation) cx.captureContinuation().getContinuation()); + pending.setTemplatePath(page); + pending.setDataModel((Map) data); + pending.setAllowCallbackResume(allowCallbackResume); + LOG.debug("Pausing flow"); + throw pending; + } finally { + Context.exit(); + } + + } + + // NOTE: do not alter this method's signature so that it returns void. The returned + // value is simulated when the continuation is resumed: see 3rd parameter in call + // to resumeContinuation (FlowService) + public static Pair pauseForExternalRedirect(String url) throws PendingRedirectException { + + Context cx = Context.enter(); + try { + PendingRedirectException pending = new PendingRedirectException( + (NativeContinuation) cx.captureContinuation().getContinuation()); + pending.setLocation(url); + pending.setAllowCallbackResume(true); + LOG.debug("Pausing flow"); + throw pending; + } finally { + Context.exit(); + } + } + + public static boolean testEquality(Object a, Object b) { + + boolean anull = a == null; + boolean bnull = b == null; + + // Same object? + if (a == b) return true; + if (!anull && !bnull) { + + Class aClass = a.getClass(); + Class bClass = b.getClass(); + if (!aClass.equals(bClass)) { + + //Native JS numbers land as double here + if (aClass.equals(Double.class) && Number.class.isInstance(b)) { + return a.equals(((Number) b).doubleValue()); + + } else if (bClass.equals(Double.class) && Number.class.isInstance(a)) { + return b.equals(((Number) a).doubleValue()); + } + + LOG.warn("Trying to compare instances of {} and {}", aClass.getName(), bClass.getName()); + + LogUtils.log("@w Equality check between % and % is not available", + simpleName(aClass), simpleName(bClass)); + + } else if (aClass.equals(String.class) || PrimitiveUtils.isPrimitive(aClass, true)) { + return a.equals(b); + } else { + LogUtils.log("@w Equality check is only effective for numbers, strings, and boolean values. " + + "It returns false in other cases"); + } + } + return false; + + } + + //Issue a call to this method only if the request scope is active + public static Function prepareSubflow(String qname, String parentBasepath, String[] pathOverrides) + throws IOException { + return managedBean(FlowService.class).prepareSubflow(qname, parentBasepath, pathOverrides); + + } + + public static Object callAction(Object instance, String actionClassName, String methodName, + Object[] params) throws Exception { + + return managedBean(ActionService.class).callAction(instance, actionClassName, methodName, params); + //TODO: remove? + //if (Map.class.isInstance(value) && !NativeJavaMap.class.equals(value.getClass())) { + // Scriptable scope = managedBean(FlowService.class).getGlobalScope(); + // return new NativeJavaMap(scope, value); + //} + + } + + //Issue a call to this method only if the request scope is active + public static void closeSubflow() throws IOException { + managedBean(FlowService.class).closeSubflow(); + } + + private static String simpleName(Class cls) { + + String name; + if (List.class.isAssignableFrom(cls)) { + name = "list"; + } else if (Map.class.isAssignableFrom(cls)) { + name = "map"; + } else { + name = cls.getSimpleName(); + } + return name; + + } + + public static T managedBean(Class cls) { + return CDI.current().select(cls).get(); + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/serialize/ContinuationSerializer.java b/agama/engine/src/main/java/io/jans/agama/engine/serialize/ContinuationSerializer.java new file mode 100644 index 00000000000..a3d12a63905 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/serialize/ContinuationSerializer.java @@ -0,0 +1,117 @@ +package io.jans.agama.engine.serialize; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.ObjectStreamClass; +import java.io.OutputStream; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import io.jans.agama.engine.service.ActionService; +import io.jans.util.Pair; + +import org.mozilla.javascript.NativeContinuation; +import org.mozilla.javascript.NativeJavaObject; +import org.mozilla.javascript.Scriptable; + +@ApplicationScoped +public class ContinuationSerializer { + + @Inject + private ActionService actionService; + + @Inject + private SerializerFactory serializerFactory; + + private static ObjectSerializer objectSerializer; + private static ClassLoader classLoader; + + public byte[] save(Scriptable scope, NativeContinuation continuation) throws IOException { + + class CustomObjectOutputStream extends ObjectOutputStream { + + CustomObjectOutputStream(OutputStream out) throws IOException { + super(out); + enableReplaceObject(true); + } + + @Override + protected Object replaceObject​(Object obj) throws IOException { + + if (NativeJavaObject.class.isInstance(obj)) { + return new NativeJavaBox((NativeJavaObject) obj); + } + return super.replaceObject(obj); + + } + + } + + try ( ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream sos = new CustomObjectOutputStream(baos)) { + + //Pair is not java-serializable, use a 2-length array + sos.writeObject(new Object[] { scope, continuation }); + return baos.toByteArray(); + } + + } + + public Pair restore(byte[] data) throws IOException { + + class CustomObjectInputStream extends ObjectInputStream { + + public CustomObjectInputStream(InputStream in) throws IOException { + super(in); + enableResolveObject(true); + } + + @Override + public Class resolveClass​(ObjectStreamClass desc) throws IOException, ClassNotFoundException { + return Class.forName(desc.getName(), false, classLoader); + } + + @Override + protected Object resolveObject​(Object obj) throws IOException { + + if (obj != null && obj.getClass().equals(NativeJavaBox.class)) { + return ((NativeJavaBox) obj).getRaw(); + } + return super.resolveObject(obj); + + } + + } + + try ( ByteArrayInputStream bais = new ByteArrayInputStream(data); + ObjectInputStream sis = new CustomObjectInputStream(bais)) { + + Object[] arr = (Object[]) sis.readObject(); + return new Pair<>((Scriptable) arr[0], (NativeContinuation) arr[1]); + + } catch (ClassNotFoundException e) { + throw new IOException(e); + } + + } + + protected static ObjectSerializer getObjectSerializer() { + return objectSerializer; + } + + protected static ClassLoader getClassLoader() { + return classLoader; + } + + @PostConstruct + private void init() { + objectSerializer = serializerFactory.get(); + classLoader = actionService.getClassLoader(); + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/serialize/FstSerializer.java b/agama/engine/src/main/java/io/jans/agama/engine/serialize/FstSerializer.java new file mode 100644 index 00000000000..5bcfcf97080 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/serialize/FstSerializer.java @@ -0,0 +1,41 @@ +package io.jans.agama.engine.serialize; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import io.jans.agama.model.serialize.Type; +import org.slf4j.Logger; + +/** + * Warning: This serialization strategy is not implemented yet + */ +@ApplicationScoped +public class FstSerializer implements ObjectSerializer { + + @Inject + private Logger logger; + + @Override + public Object deserialize(InputStream in) throws IOException { + return null; + } + + @Override + public void serialize(Object data, OutputStream out) throws IOException { + } + + @Override + public Type getType() { + return Type.FST; + } + + @PostConstruct + private void init() { + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/serialize/KryoSerializer.java b/agama/engine/src/main/java/io/jans/agama/engine/serialize/KryoSerializer.java new file mode 100644 index 00000000000..2c50429797f --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/serialize/KryoSerializer.java @@ -0,0 +1,72 @@ +package io.jans.agama.engine.serialize; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; +import com.esotericsoftware.minlog.Log; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import io.jans.agama.model.serialize.Type; +import io.jans.agama.engine.service.ActionService; + +import org.slf4j.Logger; + +@ApplicationScoped +public class KryoSerializer implements ObjectSerializer { + + @Inject + private Logger logger; + + @Inject + private ActionService actionService; + + private ThreadLocal kryos; + + @Override + public Object deserialize(InputStream in) throws IOException { + logger.trace("Kryodeserializing"); + Input input = new Input(in); + //If input is closed, the input's InputStream is closed + return kryos.get().readClassAndObject(input); + } + + @Override + public void serialize(Object data, OutputStream out) throws IOException { + logger.trace("Kryoserializing"); + Output output = new Output(out); + kryos.get().writeClassAndObject(output, data); + output.flush(); + } + + @Override + public Type getType() { + return Type.KRYO; + } + + @PostConstruct + private void init() { + + Log.DEBUG(); + kryos = new ThreadLocal() { + + @Override + protected Kryo initialValue() { + Kryo kryo = new Kryo(); + kryo.setRegistrationRequired(false); + kryo.setReferences(true); + kryo.setClassLoader(actionService.getClassLoader()); + kryo.setOptimizedGenerics(false); + return kryo; + } + + }; + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/serialize/NativeJavaBox.java b/agama/engine/src/main/java/io/jans/agama/engine/serialize/NativeJavaBox.java new file mode 100644 index 00000000000..c76134ec9ae --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/serialize/NativeJavaBox.java @@ -0,0 +1,96 @@ +package io.jans.agama.engine.serialize; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; + +import org.mozilla.javascript.NativeJavaArray; +import org.mozilla.javascript.NativeJavaClass; +import org.mozilla.javascript.NativeJavaList; +import org.mozilla.javascript.NativeJavaMap; +import org.mozilla.javascript.NativeJavaObject; +import org.mozilla.javascript.Scriptable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class NativeJavaBox implements Serializable { + + private static final long serialVersionUID = 3843792598994958978L; + private static final Logger logger = LoggerFactory.getLogger(NativeJavaBox.class); + + private NativeJavaObject raw; + private Object unwrapped; + + public NativeJavaBox(NativeJavaObject raw) { + + this.raw = raw; + unwrapped = raw.unwrap(); + + if (NativeJavaObject.class.isInstance(unwrapped)) { + throw new UnsupportedOperationException("Unexpected NativeJavaObject inside a NativeJavaObject"); + } + + logger.trace("NativeJavaBox created"); + + } + + private void writeObject(ObjectOutputStream out) throws IOException { + + String rawClsName = raw.getClass().getName(); + logger.trace("{} in the output stream", rawClsName); + + out.writeUTF(rawClsName); + out.writeObject(raw.getParentScope()); + + logger.trace("Underlying object is an instance of {}", unwrapped.getClass().getName()); + ObjectSerializer serializer = ContinuationSerializer.getObjectSerializer(); + if (serializer == null) { + out.writeObject(unwrapped); + } else { + serializer.serialize(unwrapped, out); + } + + } + + private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { + + logger.trace("Reading NativeJavaBox"); + Class rawCls = classFromName(in.readUTF()); + + logger.trace("{} in the input stream", rawCls.getName()); + Scriptable parentScope = (Scriptable) in.readObject(); + + ObjectSerializer serializer = ContinuationSerializer.getObjectSerializer(); + unwrapped = serializer == null ? in.readObject() : serializer.deserialize(in); + logger.trace("Underlying object is an instance of {}", unwrapped.getClass().getName()); + + if (rawCls.equals(NativeJavaObject.class)) { + raw = new NativeJavaObject(parentScope, unwrapped, unwrapped.getClass()); + + } else if (rawCls.equals(NativeJavaClass.class)) { + raw = new NativeJavaClass(parentScope, (Class) unwrapped); + + } else if (rawCls.equals(NativeJavaList.class)) { + raw = new NativeJavaList(parentScope, unwrapped); + + } else if (rawCls.equals(NativeJavaArray.class)) { + raw = NativeJavaArray.wrap(parentScope, unwrapped); + + } else if (rawCls.equals(NativeJavaMap.class)) { + raw = new NativeJavaMap(parentScope, unwrapped); + + } + + } + + private static Class classFromName(String qname) throws ClassNotFoundException { + return Class.forName(qname, false, ContinuationSerializer.getClassLoader()); + } + + public NativeJavaObject getRaw() { + logger.trace("Returning raw instance"); + return raw; + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/serialize/ObjectSerializer.java b/agama/engine/src/main/java/io/jans/agama/engine/serialize/ObjectSerializer.java new file mode 100644 index 00000000000..f6f72d1d62e --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/serialize/ObjectSerializer.java @@ -0,0 +1,14 @@ +package io.jans.agama.engine.serialize; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import io.jans.agama.model.serialize.Type; + +public interface ObjectSerializer { + + Object deserialize(InputStream in) throws IOException; + void serialize(Object data, OutputStream out) throws IOException; + Type getType(); + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/serialize/SerializerFactory.java b/agama/engine/src/main/java/io/jans/agama/engine/serialize/SerializerFactory.java new file mode 100644 index 00000000000..68ee0262666 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/serialize/SerializerFactory.java @@ -0,0 +1,34 @@ +package io.jans.agama.engine.serialize; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +import io.jans.agama.model.EngineConfig; + +@ApplicationScoped +public class SerializerFactory { + + @Inject + private EngineConfig engineConf; + + @Inject @Any + private Instance services; + + private ObjectSerializer serializer; + + public ObjectSerializer get() { + return serializer; + } + + @PostConstruct + private void init() { + serializer = services.stream() + .filter(s -> s.getType().equals(engineConf.getSerializerType())) + .findFirst().orElse(null); + } + + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/service/ActionService.java b/agama/engine/src/main/java/io/jans/agama/engine/service/ActionService.java new file mode 100644 index 00000000000..8cac418a105 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/service/ActionService.java @@ -0,0 +1,248 @@ +package io.jans.agama.engine.service; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; + +import groovy.lang.GroovyClassLoader; +import groovy.util.GroovyScriptEngine; +import groovy.util.ResourceException; + +import java.io.File; +import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Parameter; +import java.lang.reflect.Type; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Optional; +import java.util.function.BiPredicate; +import java.util.stream.Stream; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import io.jans.agama.engine.misc.PrimitiveUtils; +import io.jans.agama.model.EngineConfig; + +import org.slf4j.Logger; + +@ApplicationScoped +public class ActionService { + + private static final String CLASS_SUFFIX = ".groovy"; + + @Inject + private Logger logger; + + @Inject + private EngineConfig econf; + + private GroovyScriptEngine gse; + private ObjectMapper mapper; + private GroovyClassLoader loader; + + public Object callAction(Object instance, String className, String methodName, Object[] rhinoArgs) + throws Exception { + + boolean noInst = instance == null; + Class actionCls; + + if (!noInst) { + actionCls = instance.getClass(); + className = actionCls.getName(); + } else { + + try { + //logger.info("Using current classloader to load class " + className); + //Try the fastest lookup first + actionCls = Class.forName(className); + } catch (ClassNotFoundException e) { + try { + String classFilePath = className.replace('.', File.separatorChar) + CLASS_SUFFIX; + //GroovyScriptEngine classes are only really reloaded when the underlying file changes + actionCls = gse.loadScriptByName(classFilePath); + } catch (ResourceException re) { + throw new ClassNotFoundException(re.getMessage(), e); + } + } + } + logger.info("Class {} loaded successfully", className); + int arity = rhinoArgs.length; + + BiPredicate pr = (e, staticRequired) -> { + int mod = e.getModifiers(); + return e.getParameterCount() == arity && Modifier.isPublic(mod) && + (staticRequired ? Modifier.isStatic(mod) : true); + }; + + //Search for a method/constructor matching name and arity + + if (noInst && methodName.equals("new")) { + Constructor constr = Stream.of(actionCls.getConstructors()).filter(c -> pr.test(c, false)) + .findFirst().orElse(null); + if (constr == null) { + String msg = String.format("Unable to find a constructor with arity %d in class %s", + arity, className); + logger.error(msg); + throw new InstantiationException(msg); + } + + logger.debug("Constructor found"); + Object[] args = getArgsForCall(constr, arity, rhinoArgs); + + logger.debug("Creating an instance"); + return constr.newInstance(args); + } + + Method javaMethod = Stream.of(actionCls.getDeclaredMethods()) + .filter(m -> m.getName().equals(methodName)).filter(m -> pr.test(m, noInst)) + .findFirst().orElse(null); + + if (javaMethod == null) { + String msg = String.format("Unable to find a method called %s with arity %d in class %s", + methodName, arity, className); + logger.error(msg); + throw new NoSuchMethodException(msg); + } + + logger.debug("Found method {}", methodName); + Object[] args = getArgsForCall(javaMethod, arity, rhinoArgs); + + logger.debug("Performing method call"); + return javaMethod.invoke(instance, args); + + } + + private Object[] getArgsForCall(Executable javaExec, int arity, Object[] arguments) + throws IllegalArgumentException { + + Object[] javaArgs = new Object[arity]; + int i = -1; + + for (Parameter p : javaExec.getParameters()) { + Object arg = arguments[++i]; + Class paramType = p.getType(); + String typeName = paramType.getName(); + logger.debug("Examining argument at index {}", i); + + if (arg == null) { + logger.debug("Value is null"); + if (PrimitiveUtils.isPrimitive(paramType, false)) + throw new IllegalArgumentException("null value passed for a primitive parameter of type " + + typeName); + else continue; + } + if (typeName.equals(Object.class.getName())) { + //This parameter can receive anything :( + logger.trace("Parameter is a {}", typeName); + javaArgs[i] = arg; + continue; + } + + Class argClass = arg.getClass(); + + //Try to apply cheaper conversions first (in comparison to mapper-based conversion) + Boolean primCompat = PrimitiveUtils.compatible(argClass, paramType); + if (primCompat != null) { + + if (primCompat) { + logger.trace("Parameter is a primitive (or wrapped) {}", typeName); + javaArgs[i] = arg; + + } else if (argClass.equals(Double.class)) { + //Any numeric literal coming from Javascript code lands as a Double + Object number = PrimitiveUtils.primitiveNumberFrom((Double) arg, paramType); + + if (number != null) { + logger.trace("Parameter is a primitive (or wrapped) {}", typeName); + javaArgs[i] = number; + + } else mismatchError(argClass, typeName); + + } else mismatchError(argClass, typeName); + + } else if (CharSequence.class.isAssignableFrom(argClass)) { + + primCompat = PrimitiveUtils.compatible(Character.class, paramType); + + if (Optional.ofNullable(primCompat).orElse(false)) { + int len = arg.toString().length(); + if (len == 0 || len > 1) mismatchError(argClass, typeName); + + logger.trace("Parameter is a {}", typeName); + javaArgs[i] = arg.toString().charAt(0); + + } else if (paramType.isAssignableFrom(argClass)) { + logger.trace("Parameter is a {}", typeName); + javaArgs[i] = arg; + + } else mismatchError(argClass, typeName); + + } else { + //argClass should be NativeArray or NativeObject if the value was not created/derived + //from a Java call + String argClassName = argClass.getCanonicalName(); + Type parameterizedType = p.getParameterizedType(); + String ptypeName = parameterizedType.getTypeName(); + + if (ptypeName.equals(argClassName)) { + //This branch will be taken mostly when there is no type information in the parameter + //(method signature). For instance: String[], List, MyBean (no parameterized type info). + //Due to type erasure argClassName won't contain any type information. As an example, + //if arg is a List, argClassName will just be like java.util.ArrayList + javaArgs[i] = arg; + } else { + logger.warn("Trying to parse argument of class {} to {}", argClassName, ptypeName); + + JavaType javaType = mapper.getTypeFactory().constructType(parameterizedType); + javaArgs[i] = mapper.convertValue(arguments[i], javaType); + } + logger.trace("Parameter is a {}", ptypeName); + } + } + return javaArgs; + + } + + public GroovyClassLoader getClassLoader() { + return loader; + } + + private void mismatchError(Class argClass, String typeName) throws IllegalArgumentException { + throw new IllegalArgumentException(argClass.getSimpleName() + " passed for a " + typeName); + } + + @PostConstruct + private void init() { + + URL url = null; + try { + url = new URL("file://" + econf.getRootDir() + econf.getScriptsPath()); + } catch(MalformedURLException e) { + logger.error(e.getMessage()); + throw new RuntimeException(e); + } + + logger.debug("Creating a Groovy Script Engine based at {}", url.toString()); + gse = new GroovyScriptEngine(new URL[]{ url }); + /* + //Failed attempt to force scripts have java extension instead of groovy: + //Dependant scripts are not found if .groovy is not used + CompilerConfiguration cc = gse.getConfig(); + cc.setDefaultScriptExtension(CLASS_SUFFIX.substring(1)); + + //Set it so change takes effect + gse.setConfig(cc); + */ + loader = gse.getGroovyClassLoader(); + loader.setShouldRecompile(true); + + mapper = new ObjectMapper(); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/service/AgamaPersistenceService.java b/agama/engine/src/main/java/io/jans/agama/engine/service/AgamaPersistenceService.java new file mode 100644 index 00000000000..fb7cd070032 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/service/AgamaPersistenceService.java @@ -0,0 +1,218 @@ +package io.jans.agama.engine.service; + +import io.jans.agama.engine.misc.FlowUtils; +import io.jans.agama.engine.model.FlowResult; +import io.jans.agama.engine.model.FlowRun; +import io.jans.agama.engine.model.FlowStatus; +import io.jans.agama.engine.model.ProtoFlowRun; +import io.jans.agama.engine.serialize.ContinuationSerializer; +import io.jans.agama.model.Flow; +import io.jans.agama.model.ProtoFlow; +import io.jans.orm.PersistenceEntryManager; +import io.jans.orm.search.filter.Filter; +import io.jans.util.Pair; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.io.IOException; +import java.util.Base64; +import java.util.Date; +import java.util.List; + +import org.mozilla.javascript.NativeContinuation; +import org.mozilla.javascript.Scriptable; +import org.slf4j.Logger; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@ApplicationScoped +public class AgamaPersistenceService { + + public static final String AGAMA_BASE = "ou=agama,o=jans"; + public static final String AGAMA_FLOWRUNS_BASE = "ou=runs," + AGAMA_BASE; + public static final String AGAMA_FLOWS_BASE = "ou=flows," + AGAMA_BASE; + + @Inject + private Logger logger; + + @Inject + private PersistenceEntryManager entryManager; + + @Inject + private ContinuationSerializer contSerializer; + + @Inject + private FlowUtils flowUtils; + + public FlowStatus getFlowStatus(String sessionId) throws IOException { + + try { + logger.debug("Retrieving current flow's status"); + ProtoFlowRun fr = entryManager.findEntries(AGAMA_FLOWRUNS_BASE, ProtoFlowRun.class, + frEqFilter(sessionId), new String[]{ FlowRun.ATTR_NAMES.STATUS }, 1).get(0); + + return fr.getStatus(); + } catch(Exception e) { + return null; + } + + } + + public void persistFlowStatus(String sessionId, FlowStatus fst) throws IOException { + + try { + ProtoFlowRun pfr = entryManager.findEntries(AGAMA_FLOWRUNS_BASE, ProtoFlowRun.class, + frEqFilter(sessionId), new String[]{ FlowRun.ATTR_NAMES.ID }, 1).get(0); + + logger.debug("Saving current flow's status"); + pfr.setStatus(fst); + entryManager.merge(pfr); + } catch(Exception e) { + throw new IOException(e); + } + + } + + public void createFlowRun(String id, FlowStatus fst, long expireAt) throws Exception { + + FlowRun fr = new FlowRun(); + fr.setBaseDn(String.format("%s=%s,%s", FlowRun.ATTR_NAMES.ID, id, AGAMA_FLOWRUNS_BASE)); + fr.setId(id); + fr.setStatus(fst); + fr.setDeletableAt(new Date(expireAt)); + + logger.info("Creating flow run"); + entryManager.persist(fr); + + } + + public boolean flowEnabled(String flowName) { + + try { + Filter filth = Filter.createANDFilter( + Filter.createEqualityFilter(Flow.ATTR_NAMES.QNAME, flowName), + Filter.createEqualityFilter("jansEnabled", true)); + + List results = entryManager.findEntries(AGAMA_FLOWS_BASE, + ProtoFlow.class, filth, new String[]{ Flow.ATTR_NAMES.QNAME }, 1); + return results.size() == 1; + + } catch(Exception e) { + logger.error(e.getMessage(), e); + logger.warn("Flow '{}' does not seem to exist!", flowName); + return false; + } + + } + + public Flow getFlow(String flowName, boolean full) throws IOException { + + try { + String[] attrs = null; + if (!full) { + attrs = new String[]{ Flow.ATTR_NAMES.QNAME, Flow.ATTR_NAMES.META, + Flow.ATTR_NAMES.TRANSPILED }; + } + + Flow fl = entryManager.findEntries(AGAMA_FLOWS_BASE, Flow.class, + Filter.createEqualityFilter(Flow.ATTR_NAMES.QNAME, flowName), attrs, 1).get(0); + + logger.debug("Retrieving {}info of flow '{}'", full ? "" : "minimal ", flowName); + return fl; + } catch(Exception e) { + throw new IOException(e); + } + + } + + public Pair getContinuation(String sessionId) + throws IOException { + + FlowRun fr; + try { + fr = entryManager.findEntries(AGAMA_FLOWRUNS_BASE, FlowRun.class, frEqFilter(sessionId), + new String[] { "agFlowEncCont", "jansCustomMessage" }, 1).get(0); + } catch(Exception e) { + return null; + } + + logger.debug("Restoring continuation data..."); + byte[] cont = Base64.getDecoder().decode(fr.getEncodedContinuation()); + + if (!flowUtils.hash(cont).equals(fr.getHash())) + throw new IOException("Serialized continuation has been altered"); + + return contSerializer.restore(cont); + + } + + public void saveState(String sessionId, FlowStatus fst, NativeContinuation continuation, + Scriptable scope) throws IOException { + + byte[] bytes = contSerializer.save(scope, continuation); + logger.debug("Continuation serialized ({} bytes)", bytes.length); + + List results = entryManager.findEntries(AGAMA_FLOWRUNS_BASE, FlowRun.class, + frEqFilter(sessionId), new String[]{ FlowRun.ATTR_NAMES.ID, "exp" }, 1); + //The query above retrieves enough attributes so no data is lost after the + //update that follows below + + FlowRun run = results.get(0); + run.setEncodedContinuation(new String(Base64.getEncoder().encode(bytes), UTF_8)); + run.setHash(flowUtils.hash(bytes)); + //overwrite status + run.setStatus(fst); + + logger.debug("Saving state of current flow run"); + entryManager.merge(run); + + } + + public void finishFlow(String sessionId, FlowResult result) throws IOException { + + try { + logger.debug("Retrieving flow run {}", sessionId); + FlowRun run = entryManager.findEntries(AGAMA_FLOWRUNS_BASE, FlowRun.class, frEqFilter(sessionId), + new String[]{ FlowRun.ATTR_NAMES.ID, FlowRun.ATTR_NAMES.STATUS, "exp" }, 1).get(0); + + //The query above retrieves enough attributes so no data is lost after the + //update that follows below + + FlowStatus status = run.getStatus(); + status.setStartedAt(FlowStatus.FINISHED); + status.setResult(result); + + status.setQname(null); + status.setJsonInput(null); + status.setParentsData(null); + status.setTemplatePath(null); + status.setTemplateDataModel(null); + status.setExternalRedirectUrl(null); + + run.setEncodedContinuation(null); + run.setHash(null); + + logger.info("Marking flow run as finished..."); + entryManager.merge(run); + + } catch (Exception e) { + throw new IOException(e); + } + } + + public void terminateFlow(String sessionId) throws IOException { + + try { + logger.info("Removing flow run..."); + entryManager.remove(AGAMA_FLOWRUNS_BASE, FlowRun.class, frEqFilter(sessionId), 1); + } catch (Exception e) { + throw new IOException(e); + } + + } + + private Filter frEqFilter(String id) { + return Filter.createEqualityFilter(FlowRun.ATTR_NAMES.ID, id); + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/service/AppInitializer.java b/agama/engine/src/main/java/io/jans/agama/engine/service/AppInitializer.java new file mode 100644 index 00000000000..6607ddf7f9b --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/service/AppInitializer.java @@ -0,0 +1,40 @@ +package io.jans.agama.engine.service; + +import io.jans.agama.timer.ConfigReloader; +import io.jans.agama.timer.FlowRunsCleaner; +import io.jans.agama.timer.Transpilation; +import io.jans.service.cdi.event.ApplicationInitialized; +import io.jans.service.cdi.event.ApplicationInitializedEvent; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import org.slf4j.Logger; + +@ApplicationScoped +public class AppInitializer { + + @Inject + private Logger logger; + + @Inject + private Transpilation trTimer; + + @Inject + private FlowRunsCleaner fcleaner; + + @Inject + private ConfigReloader creloader; + + public void run(@Observes @ApplicationInitialized(ApplicationScoped.class) + ApplicationInitializedEvent event) { + + logger.info("Initializing Agama services"); + creloader.initTimer(); + trTimer.initTimer(); + fcleaner.initTimer(); + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/service/FlowService.java b/agama/engine/src/main/java/io/jans/agama/engine/service/FlowService.java new file mode 100644 index 00000000000..8a9d656dd9b --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/service/FlowService.java @@ -0,0 +1,411 @@ +package io.jans.agama.engine.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jans.agama.engine.continuation.PendingException; +import io.jans.agama.engine.continuation.PendingRedirectException; +import io.jans.agama.engine.continuation.PendingRenderException; +import io.jans.agama.engine.exception.FlowCrashException; +import io.jans.agama.engine.exception.FlowTimeoutException; +import io.jans.agama.engine.misc.FlowUtils; +import io.jans.agama.engine.model.FlowResult; +import io.jans.agama.engine.model.FlowStatus; +import io.jans.agama.engine.model.ParentFlowData; +import io.jans.agama.model.FlowMetadata; +import io.jans.agama.model.Config; +import io.jans.agama.model.Flow; +import io.jans.util.Pair; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import org.mozilla.javascript.Context; +import org.mozilla.javascript.ContextFactory; +import org.mozilla.javascript.ContinuationPending; +import org.mozilla.javascript.Function; +import org.mozilla.javascript.NativeContinuation; +import org.mozilla.javascript.NativeJavaList; +import org.mozilla.javascript.NativeJavaMap; +import org.mozilla.javascript.NativeObject; +import org.mozilla.javascript.RhinoException; +import org.mozilla.javascript.Scriptable; +import org.slf4j.Logger; + +@RequestScoped +public class FlowService { + + private static final String SESSION_ID_COOKIE = "session_id"; + private static final String SCRIPT_SUFFIX = ".js"; + private static final String JS_UTIL = "util.js"; + + private static final int TIMEOUT_SKEW = 8000; //millisecons + + @Inject + private Logger logger; + + @Inject + private ObjectMapper mapper; + + @Inject + private AgamaPersistenceService aps; + + @Inject + private FlowUtils flowUtils; + + @Inject + private Config config; + + @Inject + private HttpServletRequest request; + + private String sessionId; + private Context scriptCtx; + private Scriptable globalScope; + private ParentFlowData parentFlowData; + + /** + * Obtains the status of the current flow (if any) for the current user + * @return + * @throws IOException + */ + public FlowStatus getRunningFlowStatus() throws IOException { + return aps.getFlowStatus(sessionId); + } + + public boolean isEnabled(String flowName) { + return aps.flowEnabled(flowName); + } + + public FlowStatus startFlow(FlowStatus status) throws FlowCrashException { + + try { + status.setStartedAt(System.currentTimeMillis()); + String flowName = status.getQname(); + + //retrieve the flow, execute until render/redirect is reached + Flow flow = aps.getFlow(flowName, true); + FlowMetadata fl = flow.getMetadata(); + String funcName = fl.getFuncName(); + + verifyCode(flow); + logger.info("Evaluating flow code"); + + try { + globalScope = initContext(scriptCtx); + scriptCtx.evaluateString(globalScope, config.getUtilScript(), JS_UTIL, 1, null); + flowUtils.printScopeIds(globalScope); + + scriptCtx.evaluateString(globalScope, flow.getTranspiled(), flowName + SCRIPT_SUFFIX, 1, null); + flowUtils.printScopeIds(globalScope); + + logger.info("Executing function {}", funcName); + Function f = (Function) globalScope.get(funcName, globalScope); + + Object[] params = getFlowParams(fl.getInputs(), status.getJsonInput()); + NativeObject result = (NativeObject) scriptCtx.callFunctionWithContinuations(f, globalScope, params); + finishFlow(result, status); + + } catch (ContinuationPending pe) { + status = processPause(pe, status); + + } catch (Exception e){ + terminateFlow(); + makeCrashException(e); + } + + //TODO: review exception handling, enable polling if needed + } catch (IOException ie) { + throw new FlowCrashException(ie.getMessage(), ie); + } + return status; + + } + + public FlowStatus continueFlow(FlowStatus status, String jsonParameters, boolean callbackResume, + boolean abortSubflow) throws FlowCrashException, FlowTimeoutException { + + try { + if (callbackResume) { + //disable usage of callback endpoint + status.setAllowCallbackResume(false); + aps.persistFlowStatus(sessionId, status); + } + + try { + ensureTimeNotExceeded(status); + + Pair pcont = aps.getContinuation(sessionId); + globalScope = pcont.getFirst(); + flowUtils.printScopeIds(globalScope); + + logger.debug("Resuming flow"); + parentFlowData = status.getParentsData().peekLast(); + + NativeObject result = (NativeObject) scriptCtx.resumeContinuation(pcont.getSecond(), + globalScope, new Pair<>(abortSubflow, jsonParameters)); + finishFlow(result, status); + + } catch (ContinuationPending pe) { + status = processPause(pe, status); + + } catch (FlowTimeoutException te) { + terminateFlow(); + throw te; + } catch (Exception e) { + terminateFlow(); + makeCrashException(e); + } + } catch (IOException ie) { + throw new FlowCrashException(ie.getMessage(), ie); + } + return status; + + } + + // This is called in the middle of a cx.resumeContinuation invocation (see util.js#_flowCall) + public Function prepareSubflow(String subflowName, String parentBasepath, String[] pathOverrides) + throws IOException { + + Flow flow = aps.getFlow(subflowName, false); + FlowMetadata fl = flow.getMetadata(); + String funcName = fl.getFuncName(); + + String flowCodeFileName = subflowName + SCRIPT_SUFFIX; + //strangely, scriptCtx is a bit messed at this point so initialization is required again... + initContext(scriptCtx); + + scriptCtx.evaluateString(globalScope, flow.getTranspiled(), flowCodeFileName, 1, null); + flowUtils.printScopeIds(globalScope); + + logger.info("Appending function {} to scope", funcName); + Function f = (Function) globalScope.get(funcName, globalScope); + //The values set below are useful when saving the state, see method processPause + + ParentFlowData pfd = new ParentFlowData(); + pfd.setParentBasepath(parentBasepath); + pfd.setPathOverrides(pathOverrides); + parentFlowData = pfd; + + logger.info("Evaluating subflow code"); + return f; + + } + + public void ensureTimeNotExceeded(FlowStatus flstatus) throws FlowTimeoutException { + + int time = config.getEngineConf().getInterruptionTime(); + //Use some seconds to account for the potential time difference due to redirections: + //jython script -> agama, agama -> jython script. This helps agama flows to timeout + //before the unauthenticated unused time + if (time > 0 && + System.currentTimeMillis() - flstatus.getStartedAt() + TIMEOUT_SKEW > 1000 * time) { + + throw new FlowTimeoutException("You have exceeded the amount of time required " + + "to complete your authentication process", flstatus.getQname()); + //"Your authentication attempt has run for more than " + time + " seconds" + } + + } + + public void closeSubflow() throws IOException { + parentFlowData = null; + } + + public void terminateFlow() throws IOException { + aps.terminateFlow(sessionId); + } + + private void finishFlow(NativeObject result, FlowStatus status) throws IOException { + + FlowResult res = flowResultFrom(result); + status.setResult(res); + aps.finishFlow(sessionId, res); + + } + + private void verifyCode(Flow fl) throws IOException { + + String code = fl.getTranspiled(); + if (code == null) { + String msg = "Source code of flow " + fl.getQName() + " "; + msg += fl.getCodeError() == null ? "has not been parsed yet" : "has errors"; + throw new IOException(msg); + } + + if (Optional.ofNullable(config.getEngineConf().getDisableTCHV()).orElse(false)) { + + String hash = fl.getTransHash(); + //null hash means the code is being regenerated in this moment + + if (hash != null && !flowUtils.hash(code).equals(hash)) + throw new IOException("Flow code seems to have been altered. " + + "Restore the code by increasing this flow's jansRevision attribute"); + } + + } + + private FlowStatus processPause(ContinuationPending pending, FlowStatus status) + throws FlowCrashException, IOException { + + PendingException pe = null; + if (pending instanceof PendingRenderException) { + + PendingRenderException pre = (PendingRenderException) pending; + String templPath = pre.getTemplatePath(); + + if (!templPath.contains(".")) + throw new FlowCrashException( + "Expecting file extension for the template to render: " + templPath); + + status.setTemplatePath(computeTemplatePath(templPath, parentFlowData)); + status.setTemplateDataModel(pre.getDataModel()); + status.setExternalRedirectUrl(null); + pe = pre; + + } else if (pending instanceof PendingRedirectException) { + + PendingRedirectException pre = (PendingRedirectException) pending; + + status.setTemplatePath(null); + status.setTemplateDataModel(null); + status.setExternalRedirectUrl(pre.getLocation()); + pe = pre; + + } else { + throw new IllegalArgumentException("Unexpected instance of ContinuationPending"); + } + + if (parentFlowData == null) { + status.getParentsData().pollLast(); + } else { + status.getParentsData().offer(parentFlowData); + } + + status.setAllowCallbackResume(pe.isAllowCallbackResume()); + //Save the state + aps.saveState(sessionId, status, pe.getContinuation(), globalScope); + + return status; + + } + + private String computeTemplatePath(String path, ParentFlowData pfd) { + + String[] overrides = Optional.ofNullable(pfd).map(ParentFlowData::getPathOverrides) + .orElse(new String[0]); + + if (Stream.of(overrides).anyMatch(path::equals)) + return pfd.getParentBasepath() + "/" + path; + return path; + + } + + private Object[] getFlowParams(List inputNames, String strParams) throws JsonProcessingException { + + List inputs = Optional.ofNullable(inputNames).orElse(Collections.emptyList()); + Object[] params = new Object[inputs.size()]; + + if (strParams != null) { + Map map = mapper.readValue(strParams, new TypeReference>(){}); + for (int i = 0; i < params.length; i++) { + params[i] = map.get(inputs.get(i)); + } + } + for (int i = 0; i < params.length; i++) { + String input = inputs.get(i); + + if (params[i] == null) { + logger.warn("Setting parameter '{}' to null", input); + } else { + logger.debug("Setting parameter '{}' to an instance of {}", input, params[i].getClass().getName()); + + //This helps prevent exception "Invalid JavaScript value of type ..." + //when typeof is applied over this param in JavaScript code + if (Map.class.isInstance(params[i])) { + params[i] = new NativeJavaMap(globalScope, params[i]); + } else if (List.class.isInstance(params[i])) { + params[i] = new NativeJavaList(globalScope, params[i]); + } + } + } + return params; + + } + + private void makeCrashException(Exception e) throws FlowCrashException { + + String msg; + if (e instanceof RhinoException) { + RhinoException re = (RhinoException) e; + msg = re.details(); + logger.error(msg + re.getScriptStackTrace()); + //logger.error(re.getMessage()); + msg = "Error executing flow's code - " + msg; + } else + msg = e.getMessage(); + + throw new FlowCrashException(msg, e); + + } + + /** + * @param result + * @return + * @throws JsonProcessingException + */ + public FlowResult flowResultFrom(NativeObject result) throws JsonProcessingException { + return mapper.convertValue(result, FlowResult.class); + } + + private Scriptable initContext(Context ctx) { + ctx.setLanguageVersion(Context.VERSION_ES6); + ctx.setOptimizationLevel(-1); + return ctx.initStandardObjects(); + } + + @PostConstruct + private void init() { + + class AgamaContextFactory extends ContextFactory { + + @Override + protected boolean hasFeature(Context cx, int featureIndex) { + switch (featureIndex) { + case Context.FEATURE_ENABLE_JAVA_MAP_ACCESS: return true; + } + return super.hasFeature(cx, featureIndex); + } + } + + scriptCtx = new AgamaContextFactory().enterContext(); + sessionId = null; + Cookie[] cookies = request.getCookies(); + + if (cookies != null) { + sessionId = Stream.of(cookies).filter(coo -> coo.getName().equals(SESSION_ID_COOKIE)) + .findFirst().map(Cookie::getValue).orElse(null); + } + if (sessionId == null) { + logger.warn("Session ID not found"); + } + + } + + @PreDestroy + private void finish() { + Context.exit(); + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/service/ServicesFactory.java b/agama/engine/src/main/java/io/jans/agama/engine/service/ServicesFactory.java new file mode 100644 index 00000000000..78214462a2d --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/service/ServicesFactory.java @@ -0,0 +1,57 @@ +package io.jans.agama.engine.service; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jans.agama.model.Config; +import io.jans.agama.model.EngineConfig; +import io.jans.as.model.configuration.AppConfiguration; +import jakarta.annotation.PostConstruct; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; + +import org.slf4j.Logger; + +@ApplicationScoped +public class ServicesFactory { + + @Inject + private Logger logger; + + @Inject + private AppConfiguration asConfig; + + @Inject + private Config config; + + private ObjectMapper mapper; + + @Produces + public ObjectMapper mapperInstance() { + return mapper; + } + + @Produces + @ApplicationScoped + public EngineConfig engineConfigInstance() { + return config.getEngineConf(); + } + + @PostConstruct + public void init() { + + mapper = new ObjectMapper(); + EngineConfig econf = config.getEngineConf(); + + int inter = econf.getInterruptionTime(); + int unauth = asConfig.getSessionIdUnauthenticatedUnusedLifetime(); + if (inter == 0 || inter > unauth) { + //Ensure interruption time is lower than or equal to unauthenticated unused + econf.setInterruptionTime(unauth); + logger.warn("Agama flow interruption time modified to {}", unauth); + } + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/service/TemplatingService.java b/agama/engine/src/main/java/io/jans/agama/engine/service/TemplatingService.java new file mode 100644 index 00000000000..73bf224bb73 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/service/TemplatingService.java @@ -0,0 +1,81 @@ +package io.jans.agama.engine.service; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.Optional; + +import freemarker.core.OutputFormat; +import freemarker.template.Configuration; +import freemarker.template.Template; +import freemarker.template.TemplateExceptionHandler; + +import io.jans.agama.engine.exception.TemplateProcessingException; +import io.jans.agama.model.EngineConfig; + +import org.slf4j.Logger; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@ApplicationScoped +public class TemplatingService { + + @Inject + private Logger logger; + + @Inject + private EngineConfig econf; + + private Configuration fmConfig; + + public String process(String templatePath, Object dataModel, Writer writer, boolean useClassloader) + throws TemplateProcessingException { + try { + //Get template, inject data, and write output + Template t = useClassloader ? getTemplateFromClassLoader(templatePath) : getTemplate(templatePath); + t.process(Optional.ofNullable(dataModel).orElse(Collections.emptyMap()), writer); + return Optional.ofNullable(t.getOutputFormat()).map(OutputFormat::getMimeType).orElse(null); + } catch (Exception e) { + logger.error(e.getMessage(), e); + throw new TemplateProcessingException(e.getMessage(), e); + } + } + + private Template getTemplate(String path) throws IOException { + return fmConfig.getTemplate(path); + } + + private Template getTemplateFromClassLoader(String path) throws IOException { + ClassLoader loader = getClass().getClassLoader(); + Reader reader = new InputStreamReader(loader.getResourceAsStream(path), UTF_8); + return new Template(path, reader, fmConfig); + } + + @PostConstruct + private void init() { + + fmConfig = new Configuration(Configuration.VERSION_2_3_31); + fmConfig.setDefaultEncoding(UTF_8.toString()); + //TODO: ? + //fmConfig.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); + fmConfig.setTemplateExceptionHandler(TemplateExceptionHandler.DEBUG_HANDLER); + fmConfig.setLogTemplateExceptions(false); + fmConfig.setWrapUncheckedExceptions(true); + fmConfig.setFallbackOnNullLoopVariable(false); + + try { + fmConfig.setDirectoryForTemplateLoading(Paths.get(econf.getRootDir()).toFile()); + } catch(IOException e) { + logger.error("Error configuring directory for UI templates: {}", e.getMessage()); + throw new RuntimeException(e); + } + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/service/WebContext.java b/agama/engine/src/main/java/io/jans/agama/engine/service/WebContext.java new file mode 100644 index 00000000000..f04529c08d4 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/service/WebContext.java @@ -0,0 +1,58 @@ +package io.jans.agama.engine.service; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import jakarta.servlet.http.HttpServletRequest; + +import io.jans.agama.engine.servlet.RestartServlet; + +@RequestScoped +public class WebContext { + + @Inject + private HttpServletRequest request; + + private String contextPath; + private String relativePath; + private String rpFlowInitiatorUrl; + + public String getContextPath() { + return contextPath; + } + + public String getRestartUrl() { + return contextPath + RestartServlet.PATH; + } + + public String getRelativePath() { + return relativePath; + } + + public String getRpFlowInitiatorUrl() { + return rpFlowInitiatorUrl; + } + + public String getRequestUrl() { + + String queryString = request.getQueryString(); + if (queryString == null) { + queryString = ""; + } else { + queryString = "?" + queryString; + } + return request.getRequestURL().toString() + queryString; + + } + + public void setRpFlowInitiatorUrl(String rpFlowInitiatorUrl) { + this.rpFlowInitiatorUrl = rpFlowInitiatorUrl; + } + + @PostConstruct + private void init() { + contextPath = request.getContextPath(); + relativePath = request.getServletPath(); + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/servlet/BaseServlet.java b/agama/engine/src/main/java/io/jans/agama/engine/servlet/BaseServlet.java new file mode 100644 index 00000000000..6967a668bf0 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/servlet/BaseServlet.java @@ -0,0 +1,81 @@ +package io.jans.agama.engine.servlet; + +import io.jans.agama.engine.exception.TemplateProcessingException; +import io.jans.agama.engine.page.BasicTemplateModel; +import io.jans.agama.engine.page.Page; +import io.jans.agama.engine.service.TemplatingService; +import io.jans.agama.model.EngineConfig; + +import jakarta.inject.Inject; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import java.io.IOException; + +public abstract class BaseServlet extends HttpServlet { + + @Inject + private TemplatingService templatingService; + + @Inject + protected EngineConfig engineConf; + + @Inject + protected Page page; + + protected boolean isJsonRequest(HttpServletRequest request) { + return MediaType.APPLICATION_JSON.equals(request.getContentType()); + } + + protected void sendFlowTimeout(HttpServletResponse response, boolean jsonResponse, String message) + throws IOException { + + String errorPage = engineConf.getInterruptionErrorPage(); + page.setTemplatePath(jsonResponse ? engineConf.getJsonErrorPage(errorPage) : errorPage); + page.setDataModel(new BasicTemplateModel(message)); + sendPageContents(response); + + } + + protected void sendFlowCrashed(HttpServletResponse response, boolean jsonResponse, String error) + throws IOException { + + String errorPage = engineConf.getCrashErrorPage(); + page.setTemplatePath(jsonResponse ? engineConf.getJsonErrorPage(errorPage) : errorPage); + page.setRawDataModel(new BasicTemplateModel(error)); + sendPageContents(response); + + } + + protected void sendPageMismatch(HttpServletResponse response, boolean jsonResponse, String url) + throws IOException { + + String errorPage = engineConf.getPageMismatchErrorPage(); + page.setTemplatePath(jsonResponse ? engineConf.getJsonErrorPage(errorPage) : errorPage); + page.setDataModel(new BasicTemplateModel(url)); + sendPageContents(response); + + } + + protected void sendPageContents(HttpServletResponse response) throws IOException { + processTemplate(page.getTemplatePath(), page.getDataModel(), response); + } + + protected void processTemplate(String path, Object dataModel, HttpServletResponse response) + throws IOException { + + try { + engineConf.getDefaultResponseHeaders().forEach((h, v) -> response.setHeader(h, v)); + String mimeType = templatingService.process(path, dataModel, response.getWriter(), false); + if (mimeType != null) { + response.setHeader(HttpHeaders.CONTENT_TYPE, mimeType); + } + } catch (TemplateProcessingException e) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage()); + } + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/servlet/ExecutionServlet.java b/agama/engine/src/main/java/io/jans/agama/engine/servlet/ExecutionServlet.java new file mode 100644 index 00000000000..25270d6b084 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/servlet/ExecutionServlet.java @@ -0,0 +1,233 @@ +package io.jans.agama.engine.servlet; + +import jakarta.inject.Inject; +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.HttpMethod; +import java.io.IOException; +import java.util.stream.Collectors; + +import io.jans.agama.engine.exception.FlowCrashException; +import io.jans.agama.engine.exception.FlowTimeoutException; +import io.jans.agama.engine.misc.FlowUtils; +import io.jans.agama.engine.model.FlowResult; +import io.jans.agama.engine.model.FlowStatus; +import io.jans.agama.engine.service.FlowService; + +import org.slf4j.Logger; + +@WebServlet(urlPatterns = { + "*" + ExecutionServlet.URL_SUFFIX, + ExecutionServlet.CALLBACK_PATH, + ExecutionServlet.ABORT_PATH +}) +public class ExecutionServlet extends BaseServlet { + + public static final String URL_SUFFIX = ".fls"; + public static final String URL_PREFIX = "/fl/"; + public static final String CALLBACK_PATH = URL_PREFIX + "callback"; + public static final String ABORT_PATH = URL_PREFIX + "abort"; + + @Inject + private Logger logger; + + @Inject + private FlowService flowService; + + @Inject + private FlowUtils flowUtils; + + @Override + public void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + FlowStatus fstatus = flowService.getRunningFlowStatus(); + String path = request.getServletPath(); + + if (fstatus == null || fstatus.getStartedAt() == FlowStatus.FINISHED) { + sendNotFound(response); + return; + } + + //json-based clients must explicitly pass the content-type in GET requests :( + boolean jsonRequest = isJsonRequest(request); + if (fstatus.getStartedAt() == FlowStatus.PREPARED) { + logger.info("Attempting to trigger flow {}", fstatus.getQname()); + + try { + fstatus = flowService.startFlow(fstatus); + FlowResult result = fstatus.getResult(); + + if (result == null) { + sendRedirect(response, request.getContextPath(), fstatus, true); + } else { + sendFinishPage(response, jsonRequest, result); + } + } catch (FlowCrashException e) { + logger.error(e.getMessage(), e); + sendFlowCrashed(response, jsonRequest, e.getMessage()); + } + + } else { + if (processCallback(request, response, fstatus, path)) return; + + String expectedUrl = getExpectedUrl(fstatus); + + if (path.equals(expectedUrl)) { + page.setTemplatePath(engineConf.getTemplatesPath() + "/" + fstatus.getTemplatePath()); + page.setDataModel(fstatus.getTemplateDataModel()); + sendPageContents(response); + } else { + //This is an attempt to GET a page which is not the current page of this flow + //json-based clients must explicitly pass the content-type in GET requests + sendPageMismatch(response, jsonRequest, expectedUrl); + } + } + + } + + @Override + public void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + FlowStatus fstatus = flowService.getRunningFlowStatus(); + String path = request.getServletPath(); + + if (fstatus == null || fstatus.getStartedAt() == FlowStatus.FINISHED) { + sendNotFound(response); + return; + } + + if (processCallback(request, response, fstatus, path)) return; + + String expectedUrl = getExpectedUrl(fstatus); + + if (path.equals(expectedUrl)) { + continueFlow(request, response, fstatus, false, false); + } else if (path.equals(ABORT_PATH)) { + continueFlow(request, response, fstatus, false, true); + } else { + //This is an attempt to POST to a URL which is not the current page of this flow + sendPageMismatch(response, isJsonRequest(request), expectedUrl); + } + + } + + @Override + public void service(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + String method = request.getMethod(); + String path = request.getServletPath(); + boolean match = path.startsWith(URL_PREFIX); + + if (match) { + logger.debug("ExecutionServlet {} {}", method, path); + + if (method.equals(HttpMethod.GET)) { + doGet(request, response); + } else if (method.equals(HttpMethod.POST)) { + doPost(request, response); + } else { + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + } + } else { + sendNotFound(response); + logger.debug("Unexpected path {}", path); + } + + } + + private void continueFlow(HttpServletRequest request, HttpServletResponse response, FlowStatus fstatus, + boolean callbackResume, boolean abortSubflow) throws IOException { + + boolean jsonRequest = isJsonRequest(request); + try { + String jsonParams; + if (jsonRequest) { + //Obtain from payload + jsonParams = request.getReader().lines().collect(Collectors.joining()); + } else { + jsonParams = flowUtils.toJsonString(request.getParameterMap()); + } + + fstatus = flowService.continueFlow(fstatus, jsonParams, callbackResume, abortSubflow); + FlowResult result = fstatus.getResult(); + + if (result == null) { + sendRedirect(response, request.getContextPath(), fstatus, + request.getMethod().equals(HttpMethod.GET)); + } else { + sendFinishPage(response, jsonRequest, result); + } + + } catch (FlowTimeoutException te) { + sendFlowTimeout(response, jsonRequest, te.getMessage()); + + } catch (FlowCrashException ce) { + logger.error(ce.getMessage(), ce); + sendFlowCrashed(response, jsonRequest, ce.getMessage()); + } + + } + + private boolean processCallback(HttpServletRequest request, HttpServletResponse response, + FlowStatus fstatus, String path) throws IOException { + + if (path.equals(CALLBACK_PATH)) { + if (fstatus.isAllowCallbackResume()) { + continueFlow(request, response, fstatus, true, false); + } else { + logger.warn("Unexpected incoming response at flow callback endpoint"); + sendNotFound(response); + } + return true; + } + return false; + + } + + private void sendNotFound(HttpServletResponse response) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + } + + private void sendRedirect(HttpServletResponse response, String contextPath, FlowStatus fls, + boolean currentIsGet) throws IOException { + + String newLocation = fls.getExternalRedirectUrl(); + if (newLocation == null) { + // Local redirection + newLocation = contextPath + getExpectedUrl(fls); + } + //See https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections and + //https://stackoverflow.com/questions/4764297/difference-between-http-redirect-codes + if (currentIsGet) { + //This one uses 302 (Found) redirection + response.sendRedirect(newLocation); + } else { + response.setHeader(HttpHeaders.LOCATION, newLocation); + response.setStatus(HttpServletResponse.SC_SEE_OTHER); + } + + } + + private void sendFinishPage(HttpServletResponse response, boolean jsonResponse, + FlowResult result) throws IOException { + + String fpage = jsonResponse ? engineConf.getJsonFinishedFlowPage() : engineConf.getFinishedFlowPage(); + page.setTemplatePath(fpage); + page.setDataModel(result); + sendPageContents(response); + + } + + private String getExpectedUrl(FlowStatus fls) { + String templPath = fls.getTemplatePath(); + if (templPath == null) return null; + return URL_PREFIX + templPath.substring(0, templPath.lastIndexOf(".")) + URL_SUFFIX; + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/servlet/RestartServlet.java b/agama/engine/src/main/java/io/jans/agama/engine/servlet/RestartServlet.java new file mode 100644 index 00000000000..a009e519ddb --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/servlet/RestartServlet.java @@ -0,0 +1,66 @@ +package io.jans.agama.engine.servlet; + +import jakarta.inject.Inject; +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.Response; +import java.io.IOException; + +import io.jans.agama.NativeJansFlowBridge; +import io.jans.agama.engine.exception.FlowTimeoutException; +import io.jans.agama.engine.model.FlowStatus; +import io.jans.agama.engine.service.FlowService; + +import org.slf4j.Logger; + +@WebServlet(urlPatterns = RestartServlet.PATH) +public class RestartServlet extends BaseServlet { + + public static final String PATH = "/fl/restart"; + + @Inject + private Logger logger; + + @Inject + private FlowService flowService; + + @Inject + private NativeJansFlowBridge bridge; + + @Override + public void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + logger.debug("Restart servlet"); + try { + FlowStatus st = flowService.getRunningFlowStatus(); + + if (st == null || st.getStartedAt() == FlowStatus.FINISHED) { + //If flow exists, ensure it is not about to be collected by cleaner job + throw new IOException("No flow to restart"); + } else { + + try { + flowService.ensureTimeNotExceeded(st); + flowService.terminateFlow(); + + logger.debug("Sending user's browser for a flow start"); + //This relies on the (unathenticated) session id being still alive (so the + //flow name and its inputs can be remembered + String url = bridge.scriptPageUrl().replaceFirst("\\.xhtml", ".htm"); + response.sendRedirect(request.getContextPath() + "/" + url); + + } catch (FlowTimeoutException e) { + sendFlowTimeout(response, isJsonRequest(request), e.getMessage()); + } + + } + } catch (IOException e) { + response.sendError(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), e.getMessage()); + } + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/engine/servlet/StatusServlet.java b/agama/engine/src/main/java/io/jans/agama/engine/servlet/StatusServlet.java new file mode 100644 index 00000000000..072f6ee8822 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/engine/servlet/StatusServlet.java @@ -0,0 +1,26 @@ +package io.jans.agama.engine.servlet; + +import jakarta.inject.Inject; +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +import org.slf4j.Logger; + +@WebServlet(urlPatterns = "/fl/status") +public class StatusServlet extends HttpServlet { + + @Inject + private Logger logger; + + @Override + public void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/timer/ConfigReloader.java b/agama/engine/src/main/java/io/jans/agama/timer/ConfigReloader.java new file mode 100644 index 00000000000..72f4afcb82d --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/timer/ConfigReloader.java @@ -0,0 +1,79 @@ +package io.jans.agama.timer; + +import io.jans.agama.engine.service.AgamaPersistenceService; +import io.jans.agama.model.Config; +import io.jans.orm.PersistenceEntryManager; +import io.jans.service.cdi.async.Asynchronous; +import io.jans.service.cdi.event.Scheduled; +import io.jans.service.timer.event.TimerEvent; +import io.jans.service.timer.schedule.TimerSchedule; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.slf4j.Logger; + +@ApplicationScoped +public class ConfigReloader { + + private static final int DELAY = 60; //seconds + private static final int INTERVAL = 90; //seconds + + @Inject + private Logger logger; + + @Inject + private Event timerEvent; + + @Inject + private PersistenceEntryManager entryManager; + + private AtomicBoolean isActive; + + private Config config; + + @Produces + @ApplicationScoped + public Config configInstance() { + return config; + } + + private Config getConfiguration() { + logger.info("Retrieving Agama configuration"); + return entryManager.find(Config.class, AgamaPersistenceService.AGAMA_BASE); + } + + public void initTimer() { + + logger.info("Initializing Agama config reloader Timer"); + config = getConfiguration(); + + isActive = new AtomicBoolean(false); + timerEvent.fire(new TimerEvent(new TimerSchedule(DELAY, INTERVAL), + new ConfigReloaderEvent(), Scheduled.Literal.INSTANCE)); + + } + + @Asynchronous + public void run(@Observes @Scheduled ConfigReloaderEvent event) { + + if (isActive.get()) return; + + if (!isActive.compareAndSet(false, true)) return; + + try { + config = getConfiguration(); + logger.info("Agama config reloader timer has run."); + } catch (Exception e) { + logger.error("An error occurred while running agama config reloader timer", e); + } finally { + isActive.set(false); + } + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/timer/ConfigReloaderEvent.java b/agama/engine/src/main/java/io/jans/agama/timer/ConfigReloaderEvent.java new file mode 100644 index 00000000000..e12cdfeb767 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/timer/ConfigReloaderEvent.java @@ -0,0 +1,4 @@ +package io.jans.agama.timer; + +public class ConfigReloaderEvent { } + diff --git a/agama/engine/src/main/java/io/jans/agama/timer/FlowRunsCleaner.java b/agama/engine/src/main/java/io/jans/agama/timer/FlowRunsCleaner.java new file mode 100644 index 00000000000..a0390add7f5 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/timer/FlowRunsCleaner.java @@ -0,0 +1,85 @@ +package io.jans.agama.timer; + +import io.jans.agama.engine.model.FlowRun; +import io.jans.orm.PersistenceEntryManager; +import io.jans.orm.search.filter.Filter; +import io.jans.service.cdi.async.Asynchronous; +import io.jans.service.cdi.event.Scheduled; +import io.jans.service.timer.event.TimerEvent; +import io.jans.service.timer.schedule.TimerSchedule; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import java.util.Date; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.slf4j.Logger; + +import static io.jans.agama.engine.service.AgamaPersistenceService.AGAMA_FLOWRUNS_BASE; + +@ApplicationScoped +public class FlowRunsCleaner { + + private static final int DELAY = 120; //seconds + private static final int INTERVAL = 90; //seconds + private static final int GAP = 5000; //milliseconds + private static final int DEL_BATCH_SIZE = 100; + + @Inject + private Logger logger; + + @Inject + private PersistenceEntryManager entryManager; + + private AtomicBoolean isActive; + + @Inject + private Event timerEvent; + + public void initTimer() { + + logger.info("Initializing Agama runs cleaner Timer"); + isActive = new AtomicBoolean(false); + timerEvent.fire(new TimerEvent(new TimerSchedule(DELAY, INTERVAL), + new FlowRunsCleanerEvent(), Scheduled.Literal.INSTANCE)); + + } + + @Asynchronous + public void run(@Observes @Scheduled FlowRunsCleanerEvent event) { + + if (isActive.get()) return; + + if (!isActive.compareAndSet(false, true)) return; + + try { + int count = clean(); + logger.info("Flows cleaner timer has run. {} runs removed", count); + } catch (Exception e) { + logger.error("An error occurred while running flows cleaner timer", e); + } finally { + isActive.set(false); + } + + } + + private int clean() { + + //use a small delay window so flow rus are removed a bit after the expiration has occurred + Date date = new Date(System.currentTimeMillis() - GAP); + int total = 0, removed; + do { + removed = entryManager.remove(AGAMA_FLOWRUNS_BASE, FlowRun.class, + Filter.createLessOrEqualFilter("exp", entryManager.encodeTime(AGAMA_FLOWRUNS_BASE, date)), + DEL_BATCH_SIZE); + + total += removed; + logger.trace("{} entries removed", removed); + } while (removed > 0); + return total; + + } + +} diff --git a/agama/engine/src/main/java/io/jans/agama/timer/FlowRunsCleanerEvent.java b/agama/engine/src/main/java/io/jans/agama/timer/FlowRunsCleanerEvent.java new file mode 100644 index 00000000000..a9f0f41f769 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/timer/FlowRunsCleanerEvent.java @@ -0,0 +1,3 @@ +package io.jans.agama.timer; + +public class FlowRunsCleanerEvent { } diff --git a/agama/engine/src/main/java/io/jans/agama/timer/Transpilation.java b/agama/engine/src/main/java/io/jans/agama/timer/Transpilation.java new file mode 100644 index 00000000000..9d544a44cd0 --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/timer/Transpilation.java @@ -0,0 +1,206 @@ +package io.jans.agama.timer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jans.agama.dsl.Transpiler; +import io.jans.agama.dsl.TranspilerException; +import io.jans.agama.dsl.error.SyntaxException; +import io.jans.agama.engine.misc.FlowUtils; +import io.jans.agama.engine.service.AgamaPersistenceService; +import io.jans.agama.model.Flow; +import io.jans.agama.model.Flow.ATTR_NAMES; +import io.jans.agama.model.FlowMetadata; +import io.jans.agama.model.ProtoFlow; +import io.jans.orm.PersistenceEntryManager; +import io.jans.orm.search.filter.Filter; +import io.jans.service.cdi.async.Asynchronous; +import io.jans.service.cdi.event.Scheduled; +import io.jans.service.timer.event.TimerEvent; +import io.jans.service.timer.schedule.TimerSchedule; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.slf4j.Logger; + +@ApplicationScoped +public class Transpilation { + + private static final int DELAY = 10 + (int) (10 * Math.random()); //seconds + private static final int INTERVAL = 30; //TODO: adjust seconds + private static final double PR = 0.25; + + @Inject + private PersistenceEntryManager entryManager; + + @Inject + private Logger logger; + + @Inject + private Event timerEvent; + + @Inject + private ObjectMapper mapper; + + @Inject + private FlowUtils futils; + + private AtomicBoolean isActive; + + private Map traces; + + public void initTimer() { + + logger.info("Initializing Agama transpilation Timer"); + isActive = new AtomicBoolean(false); + timerEvent.fire(new TimerEvent(new TimerSchedule(DELAY, INTERVAL), + new TranspilationEvent(), Scheduled.Literal.INSTANCE)); + + } + + @Asynchronous + public void run(@Observes @Scheduled TranspilationEvent event) { + + if (isActive.get()) return; + + if (!isActive.compareAndSet(false, true)) return; + + try { + process(); + logger.debug("Transpilation timer has run."); + } catch (Exception e) { + logger.error("An error occurred while running transpilation timer", e); + } finally { + isActive.set(false); + } + + } + + private Map makeSimpleMap(Map map) { + return map.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, e -> e.getValue().getRevision())); + } + + /** + * This method assumes that when a flow is created, attribute revision is set to a negative value + * @throws IOException + */ + public void process() throws IOException { + + List flows = entryManager.findEntries(AgamaPersistenceService.AGAMA_FLOWS_BASE, + ProtoFlow.class, Filter.createEqualityFilter("jansEnabled", true), null); + + Map map = flows.stream().collect( + Collectors.toMap(ProtoFlow::getQName, Function.identity())); + + if (traces == null) { + traces = makeSimpleMap(map); + } + + List candidates = new ArrayList<>(); + for (String name : map.keySet()) { + + int rev; + Integer revision = traces.get(name); + + if (revision == null) { + //A newcomer. This script was enabled recently + candidates.add(name); + } else { + ProtoFlow pfl = map.get(name); + + if (pfl.getTransHash() == null && Math.random() < PR) { + //there might be a compilation of this script running already. + //If the node in charge of this crashed before completion, the random + //condition helps to get the job done by another node in the near future + candidates.add(name); + } else { + + rev = pfl.getRevision(); + if (rev < 0 || rev > revision) { + candidates.add(name); + } + } + } + } + + int s = candidates.size(); + if (s > 0) { + //pick only one. This is helpful in a multinode environment so not all nodes try + //to work on the same script. However, in practice s will rarelly be greater than 2 + String qname = candidates.get((int)(s * Math.random())); + logger.info("Starting transpilation of flow '{}'", qname); + + ProtoFlow pfl = map.get(qname); + pfl.setTransHash(null); //This helps prevent several nodes transpiling the same flow code + if (pfl.getRevision() < 0) { + pfl.setRevision(0); + } + + logger.debug("Marking the script is under compilation"); + entryManager.merge(pfl); + + //This time retrieve all attributes for the flow of interest + Flow fl = entryManager.findEntries(AgamaPersistenceService.AGAMA_FLOWS_BASE, + Flow.class, Filter.createEqualityFilter(ATTR_NAMES.QNAME, qname), null, 1).get(0); + + String error = null, shortError = null; + try { + List strings = Transpiler.transpile(qname, map.keySet(), fl.getSource()); + logger.debug("Successful transpilation"); + String fname = strings.remove(0); + String compiled = strings.remove(0); + + FlowMetadata meta = fl.getMetadata(); + meta.setFuncName(fname); + meta.setInputs(strings); + + fl.setMetadata(meta); + fl.setTranspiled(compiled); + fl.setTransHash(futils.hash(compiled)); + fl.setCodeError(null); + + logger.debug("Persisting changes..."); + entryManager.merge(fl); + + } catch (SyntaxException se) { + try { + error = mapper.writeValueAsString(se); + shortError = se.getMessage(); + } catch(JsonProcessingException je) { + error = je.getMessage(); + } + } catch (TranspilerException te) { + error = te.getMessage(); + } + + if (error != null) { + + logger.error("Transpilation failed!"); + if (shortError != null) { + logger.error(shortError); + } + + fl.setCodeError(error); + logger.debug("Persisting error details..."); + + entryManager.merge(fl); + logger.warn("Check database for errors"); + } + } + traces = makeSimpleMap(map); + + } + + +} diff --git a/agama/engine/src/main/java/io/jans/agama/timer/TranspilationEvent.java b/agama/engine/src/main/java/io/jans/agama/timer/TranspilationEvent.java new file mode 100644 index 00000000000..793105bb77d --- /dev/null +++ b/agama/engine/src/main/java/io/jans/agama/timer/TranspilationEvent.java @@ -0,0 +1,3 @@ +package io.jans.agama.timer; + +public class TranspilationEvent { } diff --git a/agama/engine/src/main/resources/META-INF/beans.xml b/agama/engine/src/main/resources/META-INF/beans.xml new file mode 100644 index 00000000000..d87ce4e5eac --- /dev/null +++ b/agama/engine/src/main/resources/META-INF/beans.xml @@ -0,0 +1,7 @@ + + + + diff --git a/agama/misc/bridge.py b/agama/misc/bridge.py new file mode 100644 index 00000000000..63dc73fbf79 --- /dev/null +++ b/agama/misc/bridge.py @@ -0,0 +1,173 @@ +# Janssen Project software is available under the Apache 2.0 License (2004). See http://www.apache.org/licenses/ for full text. +# Copyright (c) 2020, Janssen Project +# +from io.jans.agama import NativeJansFlowBridge +from io.jans.agama.model import Config, EngineConfig +from io.jans.as.server.service import AuthenticationService, SessionIdService +from io.jans.jsf2.service import FacesService +from io.jans.jsf2.message import FacesMessages +from io.jans.model.custom.script.type.auth import PersonAuthenticationType +from io.jans.orm import PersistenceEntryManager +from io.jans.service.cdi.util import CdiUtil +from io.jans.util import StringHelper + +from jakarta.faces.application import FacesMessage + +import java +import sys + +class PersonAuthentication(PersonAuthenticationType): + def __init__(self, currentTimeMillis): + self.currentTimeMillis = currentTimeMillis + + def init(self, customScript, configurationAttributes): + print "Agama. Initialization" + prop = "cust_param_name" + self.cust_param_name = self.configProperty(configurationAttributes, prop) + + if self.cust_param_name == None: + print "Agama. Custom parameter name not referenced via property '%s'" % prop + return False + + print "Agama. Request param '%s' will be used to pass flow inputs" % self.cust_param_name + print "Agama. Initialized successfully" + return True + + def destroy(self, configurationAttributes): + print "Agama. Destroy" + print "Agama. Destroyed successfully" + return True + + def getAuthenticationMethodClaims(self, requestParameters): + return None + + def getApiVersion(self): + return 11 + + def isValidAuthenticationMethod(self, usageType, configurationAttributes): + return True + + def getAlternativeAuthenticationMethod(self, usageType, configurationAttributes): + return None + + def authenticate(self, configurationAttributes, requestParameters, step): + + if step == 1: + print "Agama. Authenticate for step 1" + + try: + bridge = CdiUtil.bean(NativeJansFlowBridge) + result = bridge.close() + + if result == None or not result.isSuccess(): + print "Agama. Flow DID NOT finished successfully" + return False + else: + print "Agama. Flow finished successfully" + data = result.getData() + userId = data.get("userId") if data != None else None + + if userId == None: + print "Agama. No userId provided in flow result." + self.setMessageError(FacesMessage.SEVERITY_ERROR, "Unable to determine identity of user") + return False + + authenticated = CdiUtil.bean(AuthenticationService).authenticate(userId) + + if not authenticated: + print "Agama. Unable to authenticate %s" % userId + return False + except: + print "Agama. Exception: ", sys.exc_info()[1] + return False + + return True + + + def prepareForStep(self, configurationAttributes, requestParameters, step): + if step == 1: + print "Agama. Prepare for Step 1" + + session = CdiUtil.bean(SessionIdService).getSessionId() + if session == None: + print "Agama. Failed to retrieve session_id" + return False + + param = session.getSessionAttributes().get(self.cust_param_name) + if param == None: + print "Agama. Request param '%s' is missing or has no value" % self.cust_param_name + return False + + (qn, ins) = self.extractParams(param) + if qn == None: + print "Agama. Param '%s' is missing the name of the flow to be launched" % self.cust_param_name + return False + + try: + bridge = CdiUtil.bean(NativeJansFlowBridge) + running = bridge.prepareFlow(session.getId(), qn, ins) + + if running == None: + print "Agama. Flow '%s' does not exist!" % qn + return False + elif running: + print "Agama. A flow is already in course" + + print "Agama. Redirecting to start/resume agama flow '%s'..." % qn + + CdiUtil.bean(FacesService).redirectToExternalURL(bridge.getTriggerUrl()) + except: + print "Agama. An error occurred when launching flow '%s'. Check jans-auth logs" % qn + print "Agama. Exception: ", sys.exc_info()[1] + return False + #except java.lang.Throwable, ex: + # ex.printStackTrace() + # return False + return True + + def getExtraParametersForStep(self, configurationAttributes, step): + return None + + def getCountAuthenticationSteps(self, configurationAttributes): + return 1 + + def getPageForStep(self, configurationAttributes, step): + return "/" + CdiUtil.bean(NativeJansFlowBridge).scriptPageUrl() + + def getNextStep(self, configurationAttributes, requestParameters, step): + return -1 + + def getLogoutExternalUrl(self, configurationAttributes, requestParameters): + return None + + def logout(self, configurationAttributes, requestParameters): + return True + +# Misc routines + + def configProperty(self, configProperties, name): + prop = configProperties.get(name) + return None if prop == None else prop.getValue2() + + def setMessageError(self, severity, msg): + facesMessages = CdiUtil.bean(FacesMessages) + facesMessages.setKeepMessages() + facesMessages.clear() + facesMessages.add(severity, msg) + + def extractParams(self, param): + + # param must be of the form QN-INPUT where QN is the qualified name of the flow to launch + # INPUT is a JSON object that contains the arguments to use for the flow call. + # The keys of this object should match the already defined flow inputs. Ideally, and + # depending on the actual flow implementation, some keys may not even be required + # QN and INPUTS are separated by a hyphen + # INPUT must be properly URL-encoded when HTTP GET is used + + i = param.find("-") + if i == 0: + return (None, None) + elif i == -1: + return (param, None) + else: + return (param[:i], param[i:]) diff --git a/agama/misc/crash.ftl b/agama/misc/crash.ftl new file mode 100644 index 00000000000..92c3fc9fdb5 --- /dev/null +++ b/agama/misc/crash.ftl @@ -0,0 +1,13 @@ +<#ftl output_format="HTML"> + + + + +

An unexpected error ocurred:

+ +

${message!""} + +

Try again later + + + diff --git a/agama/misc/finished.ftl b/agama/misc/finished.ftl new file mode 100644 index 00000000000..1d7e8c6c299 --- /dev/null +++ b/agama/misc/finished.ftl @@ -0,0 +1,45 @@ +<#ftl output_format="HTML"> + + + + <#if success>Redirecting you...<#else>Error :(</#if> + + + <#if !success> + + + + + +<#if success> + +

Almost done!

+ +

Redirecting you... + +

+