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()); + } +}