diff --git a/doc/src/asciidoc/book.adoc b/doc/src/asciidoc/book.adoc
index b302e0ef2b..150f2f99e3 100644
--- a/doc/src/asciidoc/book.adoc
+++ b/doc/src/asciidoc/book.adoc
@@ -2,7 +2,7 @@ jPOS Extended Edition
=====================
:author: Alejandro Revilla
:email: apr@jpos.org
-:jposee_version: 2.2.6-SNAPSHOT
+:jposee_version: 2.2.7-SNAPSHOT
:revdate: {localdate}
:revnumber: {jposee_version}
:toc:
diff --git a/doc/src/asciidoc/module_qrest.adoc b/doc/src/asciidoc/module_qrest.adoc
index 306bccccb5..d1b00ae867 100644
--- a/doc/src/asciidoc/module_qrest.adoc
+++ b/doc/src/asciidoc/module_qrest.adoc
@@ -300,4 +300,85 @@ Here is a copy of the internal Q2Info route configuration:
If we just call `/q2`, it will output them all.
+==== Static and Dynamic HTML content
+
+QRest is by no means a full fledged web server, but it can still serve static and
+dynamic HTML pages using the `StaticContent` and `DynamicContent` participants.
+
+Our qrest TXNMGR configuration can include static content like this:
+
+[source,xml]
+------------
+
+
+
+
+
+
+
+
+
+
+------------
+
+So a call to `http://localhost:8080/welcome.html` will land in group named `welcome` that
+will serve the file `html/welcome.html`. If instead of hitting `welcome.html` the user
+tries anything else, it will fail with a 404 error.
+
+In order to serve _any_ file inside the `documentRoot`, one can omit the property
+`content`, i.e.:
+
+[source,xml]
+------------
+
+
+
+
+
+
+
+
+
+------------
+
+In this case, any file in the `static` directory will be served, if present.
+
+In addition to static files, QRest can render dynamic content using Freemarker.
+
+The configuration looks like this:
+
+[source,xml]
+------------
+
+
+
+
+
+
+
+ <1>
+
+
+
+
+------------
+<1> For security, the template file has to be specified.
+
+The `DynamicContent` class uses a special qrest Constant `RENDER_CONTEXT` with a
+map to be passed to the Freemarker template engine. Properties starting with the
+prefix `page.ctx.` will be processed at participant initialization time and
+handed to the template engine at process time. In this example, a property called
+`include` and `myprop` will be available to the template engine, and can be used to write
+a template like this:
+
+[source,html]
+-------------
+
Dynamic Content
+
+Processing transaction ${id} <1>
+
+<#include include>
+-------------
+<1> The 'id' property is also provided by the `DynamicContent` participant using the
+ transaction id.
diff --git a/modules/qrest/build.gradle b/modules/qrest/build.gradle
index 9c0409ca69..1edd2d1791 100644
--- a/modules/qrest/build.gradle
+++ b/modules/qrest/build.gradle
@@ -5,6 +5,7 @@ dependencies {
compile libraries.jacksonDatabind
compile libraries.nettyHandler
compile libraries.nettyCodecHttp
+ compile libraries.freemarker
compile jsonSchemaValidatorLibs
testCompile libraries.junit
testCompile libraries.restAssured
diff --git a/modules/qrest/src/dist/deploy/30_qrest_txnmgr.xml b/modules/qrest/src/dist/deploy/30_qrest_txnmgr.xml
index eafd8b1b54..8dc79fff9e 100644
--- a/modules/qrest/src/dist/deploy/30_qrest_txnmgr.xml
+++ b/modules/qrest/src/dist/deploy/30_qrest_txnmgr.xml
@@ -7,11 +7,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/qrest/src/main/java/org/jpos/qrest/Constants.java b/modules/qrest/src/main/java/org/jpos/qrest/Constants.java
index e9e22b7549..7acdb7831b 100644
--- a/modules/qrest/src/main/java/org/jpos/qrest/Constants.java
+++ b/modules/qrest/src/main/java/org/jpos/qrest/Constants.java
@@ -25,4 +25,5 @@ public enum Constants {
QUERYPARAMS,
PATHPARAMS,
RESPONSE,
+ RENDER_CONTEXT
}
diff --git a/modules/qrest/src/main/java/org/jpos/qrest/participant/DynamicContent.java b/modules/qrest/src/main/java/org/jpos/qrest/participant/DynamicContent.java
new file mode 100644
index 0000000000..5f2c48fb64
--- /dev/null
+++ b/modules/qrest/src/main/java/org/jpos/qrest/participant/DynamicContent.java
@@ -0,0 +1,90 @@
+/*
+ * jPOS Project [http://jpos.org]
+ * Copyright (C) 2000-2019 jPOS Software SRL
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jpos.qrest.participant;
+
+import freemarker.cache.FileTemplateLoader;
+import freemarker.template.Template;
+import freemarker.template.TemplateException;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import org.jpos.core.Configuration;
+import org.jpos.core.ConfigurationException;
+import org.jpos.transaction.Context;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.Serializable;
+import java.io.StringWriter;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static freemarker.template.Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS;
+import static org.jpos.qrest.Constants.RENDER_CONTEXT;
+
+public class DynamicContent extends StaticContent {
+ private freemarker.template.Configuration fcfg;
+ private Map pageContext = new LinkedHashMap<>();
+
+ @Override
+ public int prepare(long id, Serializable context) {
+ Context ctx = (Context) context;
+ Map rctx = getRenderContext(ctx);
+ rctx.put ("id", id);
+ rctx.putAll(pageContext);
+ return super.prepare(id, context);
+ }
+
+ @Override
+ public void setConfiguration(Configuration cfg) throws ConfigurationException {
+ super.setConfiguration(cfg);
+ fcfg = new freemarker.template.Configuration(DEFAULT_INCOMPATIBLE_IMPROVEMENTS);
+ try {
+ fcfg.setDirectoryForTemplateLoading(documentRoot);
+ fcfg.setTemplateLoader(new FileTemplateLoader(documentRoot));
+ } catch (IOException e) {
+ throw new ConfigurationException (e);
+ }
+ cfg.keySet()
+ .stream()
+ .filter (s -> s.startsWith("page.ctx"))
+ .forEach(s -> pageContext.put(s.substring(9), cfg.get(s)));
+ }
+
+ @Override
+ protected ByteBuf toByteBuf (Context ctx, File f) throws IOException {
+ Template template = fcfg.getTemplate(f.getName());
+ StringWriter sw=new StringWriter();
+ try {
+ template.process(getRenderContext(ctx),sw);
+ return Unpooled.wrappedBuffer(sw.toString().getBytes());
+ } catch (TemplateException e) {
+ throw new IOException (e);
+ }
+ }
+
+ private Map getRenderContext (Context ctx) {
+ Map rctx = ctx.get(RENDER_CONTEXT);
+ if (rctx == null) {
+ rctx = new HashMap<>();
+ ctx.put (RENDER_CONTEXT, rctx);
+ }
+ return rctx;
+ }
+}
diff --git a/modules/qrest/src/main/java/org/jpos/qrest/participant/StaticContent.java b/modules/qrest/src/main/java/org/jpos/qrest/participant/StaticContent.java
new file mode 100644
index 0000000000..c4a082ef97
--- /dev/null
+++ b/modules/qrest/src/main/java/org/jpos/qrest/participant/StaticContent.java
@@ -0,0 +1,146 @@
+/*
+ * jPOS Project [http://jpos.org]
+ * Copyright (C) 2000-2019 jPOS Software SRL
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jpos.qrest.participant;
+
+import java.io.*;
+import java.net.URI;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.Files;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.handler.codec.http.*;
+import org.jpos.core.Configurable;
+import org.jpos.core.Configuration;
+import org.jpos.core.ConfigurationException;
+import org.jpos.transaction.Context;
+import org.jpos.transaction.TransactionParticipant;
+
+import javax.activation.MimetypesFileTypeMap;
+
+import static io.netty.handler.codec.http.HttpHeaderNames.*;
+import static io.netty.handler.codec.http.HttpResponseStatus.OK;
+import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;
+import static org.jpos.qrest.Constants.REQUEST;
+import static org.jpos.qrest.Constants.RESPONSE;
+
+public class StaticContent implements TransactionParticipant, Configurable {
+ protected File content;
+ protected File documentRoot;
+ private MimetypesFileTypeMap mimeTypesMap = new MimetypesFileTypeMap();
+ private SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
+
+ private static final String HTTP_DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz";
+ private static final String HTTP_DATE_GMT_TIMEZONE = "GMT";
+ private static final int HTTP_CACHE_SECONDS = 60;
+
+ public StaticContent() {
+ dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
+ }
+
+ @Override
+ public int prepare(long id, Serializable context) {
+ Context ctx = (Context) context;
+ FullHttpRequest request = ctx.get(REQUEST);
+ QueryStringDecoder decoder = new QueryStringDecoder(request.uri());
+ String path = URI.create(decoder.uri()).getPath();
+
+ try {
+ File bodyFile = content != null ? content : jailedFile(documentRoot.getCanonicalFile(), path);
+ if (!bodyFile.canRead() || !bodyFile.isFile())
+ throw new IOException ("Unable to read '" + bodyFile + "'");
+ ByteBuf body = toByteBuf(ctx, bodyFile);
+ HttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, OK, body);
+ setHeaders(response, bodyFile);
+ ctx.put(RESPONSE, response);
+ }
+ catch (AccessDeniedException e) {
+ ctx.log(e.getMessage());
+ ctx.put(RESPONSE, new DefaultFullHttpResponse(HTTP_1_1, HttpResponseStatus.FORBIDDEN));
+ }
+ catch (IOException e) {
+ ctx.log(e.getMessage());
+ ctx.put(RESPONSE, new DefaultFullHttpResponse(HTTP_1_1, HttpResponseStatus.NOT_FOUND));
+ }
+ return PREPARED | NO_JOIN | READONLY;
+ }
+
+
+ @Override
+ public void setConfiguration(Configuration cfg) throws ConfigurationException {
+ this.documentRoot = toFile(cfg.get("documentRoot", null));
+ this.content = toFile(documentRoot, cfg.get("content", null));
+
+ if (documentRoot == null)
+ throw new ConfigurationException ("no documentRoot");
+ }
+
+ protected File toFile (String s) throws ConfigurationException {
+ File f = null;
+ if (s != null) {
+ f = new File(s);
+ if (!f.canRead())
+ throw new ConfigurationException ("Can't access '" + f.toString() + "'");
+ }
+ return f;
+ }
+ protected File toFile (File parent, String s) throws ConfigurationException {
+ File f = null;
+ if (s != null) {
+ f = new File(parent, s);
+ if (!f.canRead())
+ throw new ConfigurationException ("Can't access '" + f.toString() + "'");
+ }
+ return f;
+ }
+
+ protected ByteBuf toByteBuf (Context ctx, File f) throws IOException {
+ return Unpooled.wrappedBuffer(Files.readAllBytes(f.toPath()));
+ }
+
+ private void setHeaders(HttpResponse response, File f) {
+ Calendar time = new GregorianCalendar();
+ time.add(Calendar.SECOND, HTTP_CACHE_SECONDS);
+ response.headers().set(DATE, dateFormatter.format(time.getTime()));
+ response.headers().set(EXPIRES, dateFormatter.format(time.getTime()));
+ response.headers().set(CACHE_CONTROL, "private, max-age=" + HTTP_CACHE_SECONDS);
+ response.headers().set(LAST_MODIFIED, dateFormatter.format(f.lastModified()));
+ response.headers().set(CONTENT_TYPE, mimeTypesMap.getContentType(f.getPath()));
+ }
+
+ private File jailedFile (File parent, String path) throws IOException {
+ File f = new File(parent, path);
+ if (!isInTree(parent, f))
+ throw new AccessDeniedException("Invalid path '" + f.getCanonicalPath() + " not child of " + parent);
+ return f;
+
+ }
+
+ private boolean isInTree (File parent, File f) throws IOException {
+ f = f.getCanonicalFile();
+ if (f.getParentFile() == null)
+ return false;
+ else if (f.getCanonicalFile().getParentFile().equals(parent))
+ return true;
+ else
+ return isInTree (parent, f.getParentFile());
+ }
+}