diff --git a/.gitignore b/.gitignore index 5239179..089cffb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,10 @@ demo/log4j2.xml demo/README.md demo/server.yaml .vscode/settings.json +test-server/logs +test-server/cms.pid +test-server/modules +test-server/hosts/demo/modules_data/ +test-server/hosts/demo/temp/ +test-server/hosts/demo/data/ +.DS_Store diff --git a/demo/hosts/demo/assets/form-1.js b/demo/hosts/demo/assets/form-1.js index 618a3db..e6be660 100644 --- a/demo/hosts/demo/assets/form-1.js +++ b/demo/hosts/demo/assets/form-1.js @@ -10,56 +10,11 @@ const generateString = (length) => { return result; } -const validateCaptcha = async (event) => { - event.preventDefault(); - let request = { - code: document.getElementById("inputCaptcha").value, - key: document.getElementById("captchaKey").value - } - - const response = await fetch('/module/forms-module/captcha/validate', { - method: 'POST', - body: JSON.stringify(request) - }) - - const validationResponse = await response.json() - - if (!validationResponse.valid) { - alert("captcha code is not valid") - event.preventDefault() - return false - } else { - console.log(event.target) - event.target.submit() - return true - } -} - -const ajaxValidateCaptcha = async () => { - let request = { - code: document.getElementById("inputCaptcha").value, - key: document.getElementById("captchaKey").value - } - - const response = await fetch('/module/forms-module/captcha/validate', { - method: 'POST', - body: JSON.stringify(request) - }) - - const validationResponse = await response.json() - - if (!validationResponse.valid) { - return false - } else { - return true - } -} - document.addEventListener("DOMContentLoaded", () => { if (document.getElementById("reloadCaptcha")) { document.getElementById("reloadCaptcha").addEventListener("click", () => { let href = new URL(document.getElementById("captchaImg").src) - let key = generateString(8) + let key = crypto.randomUUID().replaceAll('-', '') + crypto.randomUUID().replaceAll('-', '') href.searchParams.set('key', key) document.getElementById("captchaKey").value = key @@ -70,36 +25,20 @@ document.addEventListener("DOMContentLoaded", () => { if (document.getElementById("ajaxForm")) { document.getElementById("ajaxForm").addEventListener("submit", (event) => { event.preventDefault() - console.log("send form via ajax") - if (!ajaxValidateCaptcha()) { - alert("invalid captcha provided"); - return false - } var form = event.target; - var formData = new FormData(form); + var formData = new URLSearchParams(new FormData(form)); fetch(form.action, { method: "post", + headers: {"Content-Type": "application/x-www-form-urlencoded"}, body: formData - }).then(res => res.json()).then(console.log); + }).then(res => res.json()).then(result => { + if (!result.success) { + alert(result.code || "The form could not be submitted") + } + }); return false }) - document.getElementById("submit-btn-test").addEventListener("click", (event) => { - event.preventDefault() - - if (ajaxValidateCaptcha()) { - alert("invalid captcha provided"); - return false - } - - var form = document.getElementById("ajaxForm") - var formData = new FormData(form); - fetch(form.action, { - method: "post", - body: formData - }).then(res => res.json()).then(console.log); - return false; - }) } }) diff --git a/demo/hosts/demo/config/forms.yaml b/demo/hosts/demo/config/forms.yaml index e73dc41..5a515c2 100644 --- a/demo/hosts/demo/config/forms.yaml +++ b/demo/hosts/demo/config/forms.yaml @@ -8,12 +8,36 @@ forms: - name: contact to: contact@example.com subject: Ich suche Kontakt! - fields: [message] + fields: + from: + type: email + required: true + message: + required: true + minLength: 10 + maxLength: 5000 + mail: + account: default + from: forms@example.com + spam: + honeypot: + enabled: true + field: website redirects: success: /forms/contact/success - name: test-form - fields: [message] + fields: + from: + type: email + required: true + message: + required: true + minLength: 3 + spam: + honeypot: + enabled: true + field: website redirects: success: /forms/contact/success redirects: - error: /forms/error \ No newline at end of file + error: /forms/error diff --git a/demo/hosts/demo/templates/ajax.html b/demo/hosts/demo/templates/ajax.html index 2760a19..35af069 100644 --- a/demo/hosts/demo/templates/ajax.html +++ b/demo/hosts/demo/templates/ajax.html @@ -17,11 +17,12 @@

Test Formular

+
@@ -31,7 +32,7 @@

Test Formular

- + reload
@@ -49,4 +50,4 @@

Test Formular

- \ No newline at end of file + diff --git a/demo/hosts/demo/templates/contact.html b/demo/hosts/demo/templates/contact.html index 2a07bfc..d7215fb 100644 --- a/demo/hosts/demo/templates/contact.html +++ b/demo/hosts/demo/templates/contact.html @@ -13,14 +13,14 @@
-
+

Contact form

+
@@ -30,7 +30,7 @@

Contact form

- + reload
@@ -47,4 +47,4 @@

Contact form

- \ No newline at end of file + diff --git a/demo/hosts/demo/templates/page.html b/demo/hosts/demo/templates/page.html index 4c89e20..b1e4fa8 100644 --- a/demo/hosts/demo/templates/page.html +++ b/demo/hosts/demo/templates/page.html @@ -17,10 +17,10 @@

Test Formular

+
@@ -30,7 +30,7 @@

Test Formular

- + reload
@@ -48,4 +48,4 @@

Test Formular

- \ No newline at end of file + diff --git a/module.properties b/module/module.properties similarity index 100% rename from module.properties rename to module/module.properties diff --git a/module/pom.xml b/module/pom.xml new file mode 100644 index 0000000..a879ed1 --- /dev/null +++ b/module/pom.xml @@ -0,0 +1,172 @@ + + + 4.0.0 + + + com.condation.cms.modules + forms-module-parent + 4.1.0 + + + forms-module + jar + + + + + net.logicsquad + nanocaptcha + + + org.slf4j + slf4j-api + + + + + com.github.ben-manes.caffeine + caffeine + + + org.yaml + snakeyaml + + + com.google.code.gson + gson + + + + org.junit.jupiter + junit-jupiter + test + + + com.icegreen + greenmail-junit5 + test + + + org.slf4j + slf4j-api + + + + + org.assertj + assertj-core + test + + + + com.condation.cms + cms-test-server + test + + + com.microsoft.playwright + playwright + test + + + + + com.condation.cms + cms-api + provided + + + com.condation.modules.framework + modules-api + provided + + + org.projectlombok + lombok + provided + + + + + + maven-assembly-plugin + 3.8.0 + + + src/main/assembly/assembly.xml + + ${module.id} + + + + package + + single + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + deploy-module-to-test-server + pre-integration-test + + copy-resources + + + ${project.basedir}/../test-server/modules + true + + + ${project.build.directory}/${module.id}-bin + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.4 + + --enable-preview + + + + + integration-test + verify + + + + + + org.apache.maven.plugins + maven-clean-plugin + 3.2.0 + + + remove-module-from-test-server + verify + + clean + + + true + + + ${project.basedir}/../test-server/modules/${module.id} + + + + + + + + + diff --git a/src/main/assembly/assembly.xml b/module/src/main/assembly/assembly.xml similarity index 97% rename from src/main/assembly/assembly.xml rename to module/src/main/assembly/assembly.xml index 5bf5a85..998989a 100644 --- a/src/main/assembly/assembly.xml +++ b/module/src/main/assembly/assembly.xml @@ -4,6 +4,7 @@ bin zip + dir diff --git a/module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java b/module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java new file mode 100644 index 0000000..6232c13 --- /dev/null +++ b/module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java @@ -0,0 +1,222 @@ +package com.condation.cms.modules.forms; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import lombok.Data; + +/** + * + * @author t.marx + */ +@Data +public class FormsConfig { + + private List forms; + + private Redirects redirects; + + private RateLimit captchaRateLimit = RateLimit.captchaDefaults(); + + private Csrf csrf = new Csrf(); + + public Optional findForm (final String name) { + if (name == null || forms == null) { + return Optional.empty(); + } + return forms.stream() + .filter(form -> form != null && name.equals(form.getName())) + .findFirst(); + } + + public void validate() { + if (forms == null || forms.isEmpty()) { + throw new IllegalArgumentException("At least one form must be configured"); + } + var names = new java.util.HashSet(); + for (var form : forms) { + if (form == null || isBlank(form.getName())) { + throw new IllegalArgumentException("Every form needs a name"); + } + if (!names.add(form.getName())) { + throw new IllegalArgumentException("Duplicate form name: " + form.getName()); + } + if (!isBlank(form.getTo()) && (form.getMail() == null || isBlank(form.getMail().getFrom()))) { + throw new IllegalArgumentException("Form '%s' needs mail.from when to is configured".formatted(form.getName())); + } + for (var entry : form.getFields().entrySet()) { + entry.getValue().validate(form.getName(), entry.getKey()); + } + validateRateLimit(form.getRateLimit(), "form " + form.getName()); + if (form.getSpam() != null && form.getSpam().getHoneypot() != null + && form.getSpam().getHoneypot().isEnabled() + && isBlank(form.getSpam().getHoneypot().getField())) { + throw new IllegalArgumentException("Enabled honeypot needs a field for form " + form.getName()); + } + safeRedirect(form.getRedirects() == null ? null : form.getRedirects().getSuccess(), null); + safeRedirect(form.getRedirects() == null ? null : form.getRedirects().getError(), null); + } + safeRedirect(redirects == null ? null : redirects.getSuccess(), null); + safeRedirect(redirects == null ? null : redirects.getError(), null); + validateRateLimit(captchaRateLimit, "captcha"); + } + + private static void validateRateLimit(final RateLimit rateLimit, final String scope) { + if (rateLimit != null && rateLimit.isEnabled() + && (rateLimit.getRequests() < 1 || rateLimit.getPeriodSeconds() < 1)) { + throw new IllegalArgumentException("Invalid rate limit for " + scope); + } + } + + public String successRedirect(final Form form) { + return safeRedirect( + form.getRedirects() == null ? null : form.getRedirects().getSuccess(), + safeRedirect(redirects == null ? null : redirects.getSuccess(), "/")); + } + + public String errorRedirect(final Form form) { + return safeRedirect( + form == null || form.getRedirects() == null ? null : form.getRedirects().getError(), + safeRedirect(redirects == null ? null : redirects.getError(), "/")); + } + + private static String safeRedirect(final String redirect, final String fallback) { + if (isBlank(redirect)) { + return fallback; + } + if (!redirect.startsWith("/") || redirect.startsWith("//") + || redirect.contains("\r") || redirect.contains("\n")) { + throw new IllegalArgumentException("Redirects must be local absolute paths: " + redirect); + } + return redirect; + } + + private static boolean isBlank(final String value) { + return value == null || value.isBlank(); + } + + @Data + public static class Form { + private String name; + private Redirects redirects; + private Map fields = new LinkedHashMap<>(); + private String to; + private String subject; + private Map data; + private Mail mail = new Mail(); + private Spam spam = new Spam(); + private RateLimit rateLimit = new RateLimit(); + private Captcha captcha = new Captcha(); + + public void setFields(final Map configuredFields) { + this.fields = configuredFields == null + ? new LinkedHashMap<>() + : new LinkedHashMap<>(configuredFields); + } + } + + @Data + public static class Field { + private String type = "string"; + private boolean required; + private Integer minLength; + private Integer maxLength; + private String pattern; + private Set allowedValues = Collections.emptySet(); + + void validate(final String formName, final String fieldName) { + if (!Set.of("string", "email", "integer", "boolean").contains(type)) { + throw new IllegalArgumentException("Unsupported type for %s.%s: %s" + .formatted(formName, fieldName, type)); + } + if (minLength != null && minLength < 0 + || maxLength != null && maxLength < 0 + || minLength != null && maxLength != null && minLength > maxLength) { + throw new IllegalArgumentException("Invalid length constraints for " + formName + "." + fieldName); + } + if (pattern != null) { + try { + Pattern.compile(pattern); + } catch (PatternSyntaxException ex) { + throw new IllegalArgumentException("Invalid pattern for " + formName + "." + fieldName, ex); + } + } + } + } + + @Data + public static class Redirects { + private String error; + private String success; + } + + @Data + public static class Mail { + private String account = "default"; + private String from; + } + + @Data + public static class Spam { + private Honeypot honeypot = new Honeypot(); + } + + @Data + public static class Honeypot { + private boolean enabled; + private String field = "website"; + } + + @Data + public static class RateLimit { + private boolean enabled = true; + private int requests = 5; + private long periodSeconds = 600; + + static RateLimit captchaDefaults() { + var result = new RateLimit(); + result.setRequests(20); + result.setPeriodSeconds(60); + return result; + } + } + + @Data + public static class Csrf { + private boolean enabled = true; + private Set allowedOrigins = Collections.emptySet(); + } + + @Data + public static class Captcha { + private boolean enabled = true; + } +} diff --git a/module/src/main/java/com/condation/cms/modules/forms/FormsFeature.java b/module/src/main/java/com/condation/cms/modules/forms/FormsFeature.java new file mode 100644 index 0000000..c90592a --- /dev/null +++ b/module/src/main/java/com/condation/cms/modules/forms/FormsFeature.java @@ -0,0 +1,94 @@ +package com.condation.cms.modules.forms; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +import com.condation.cms.api.feature.Feature; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.time.Duration; +import java.time.Instant; + +/** + * Site-local state of the forms module. + */ +public final class FormsFeature implements Feature { + + private final FormsConfig config; + private final Cache captchas; + private final Cache rateLimits; + + public FormsFeature(final FormsConfig config) { + this.config = config; + this.captchas = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofMinutes(5)) + .build(); + this.rateLimits = Caffeine.newBuilder() + .maximumSize(50_000) + .expireAfterAccess(Duration.ofHours(1)) + .build(); + } + + public FormsConfig config() { + return config; + } + + public Cache captchas() { + return captchas; + } + + public boolean allow(final String key, final FormsConfig.RateLimit policy) { + if (policy == null || !policy.isEnabled()) { + return true; + } + + var window = rateLimits.get(key, ignored -> new RateLimitWindow()); + return window.tryAcquire(policy.getRequests(), Duration.ofSeconds(policy.getPeriodSeconds())); + } + + public record CaptchaChallenge(String answer, String formName, int attempts) { + + public CaptchaChallenge failedAttempt() { + return new CaptchaChallenge(answer, formName, attempts + 1); + } + } + + private static final class RateLimitWindow { + + private Instant startedAt = Instant.now(); + private int requests; + + synchronized boolean tryAcquire(final int maximumRequests, final Duration period) { + var now = Instant.now(); + if (!now.isBefore(startedAt.plus(period))) { + startedAt = now; + requests = 0; + } + if (requests >= maximumRequests) { + return false; + } + requests++; + return true; + } + } +} diff --git a/src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java b/module/src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java similarity index 81% rename from src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java rename to module/src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java index 066b1fc..4909336 100644 --- a/src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java +++ b/module/src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java @@ -26,7 +26,6 @@ import com.condation.cms.api.extensions.HttpHandlerExtensionPoint; import com.condation.cms.api.extensions.Mapping; import com.condation.cms.api.feature.features.HookSystemFeature; -import com.condation.cms.modules.forms.handler.AjaxCaptchaValidationHandler; import com.condation.cms.modules.forms.handler.AjaxSubmitFormHandler; import com.condation.cms.modules.forms.handler.GenerateCaptchaHandler; import com.condation.cms.modules.forms.handler.SubmitFormHandler; @@ -44,13 +43,12 @@ public class FormsHttpHandlerExtension extends HttpHandlerExtensionPoint { public Mapping getMapping() { Mapping mapping = new Mapping(); - mapping.add(PathSpec.from("/captcha/validate"), new AjaxCaptchaValidationHandler()); - mapping.add(PathSpec.from("/captcha/generate"), new GenerateCaptchaHandler()); + mapping.add(PathSpec.from("/captcha/generate"), new GenerateCaptchaHandler(getContext())); mapping.add(PathSpec.from("/form/submit/ajax"), - new AjaxSubmitFormHandler(requestContext.get(HookSystemFeature.class).hookSystem(), getContext()) + new AjaxSubmitFormHandler(getRequestContext().get(HookSystemFeature.class).hookSystem(), getContext()) ); mapping.add(PathSpec.from("/form/submit"), - new SubmitFormHandler(requestContext.get(HookSystemFeature.class).hookSystem(), getContext()) + new SubmitFormHandler(getRequestContext().get(HookSystemFeature.class).hookSystem(), getContext()) ); return mapping; diff --git a/src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java b/module/src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java similarity index 70% rename from src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java rename to module/src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java index ef7c55f..3e6d5ed 100644 --- a/src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java +++ b/module/src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java @@ -25,47 +25,43 @@ import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.module.SiteModuleContext; -import com.condation.cms.api.module.SiteRequestContext; import com.condation.modules.api.ModuleLifeCycleExtension; import com.condation.modules.api.annotation.Extension; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; -import lombok.extern.slf4j.Slf4j; import org.yaml.snakeyaml.Yaml; /** * * @author t.marx */ -@Slf4j @Extension(ModuleLifeCycleExtension.class) -public class FormsLifecycleExtension extends ModuleLifeCycleExtension { - - public static Cache CAPTCHAS; - public static FormsConfig FORMSCONFIG; +public class FormsLifecycleExtension extends ModuleLifeCycleExtension { @Override public void init() { - } @Override public void activate() { - CAPTCHAS = Caffeine.newBuilder() - .maximumSize(10_000) - .expireAfterWrite(Duration.ofMinutes(5)) - .build(); - Path formsConfig = getContext().get(DBFeature.class).db().getFileSystem().resolve("config/forms.yaml"); try { - FORMSCONFIG = new Yaml().loadAs(Files.readString(formsConfig, StandardCharsets.UTF_8), FormsConfig.class); - } catch (IOException ex) { - log.error(null, ex); + var config = new Yaml().loadAs( + Files.readString(formsConfig, StandardCharsets.UTF_8), + FormsConfig.class); + if (config == null) { + throw new IllegalArgumentException("forms.yaml is empty"); + } + config.validate(); + getContext().add(FormsFeature.class, new FormsFeature(config)); + } catch (IOException | RuntimeException ex) { + System.getLogger(getClass().getName()).log( + System.Logger.Level.ERROR, + "Could not activate forms module: invalid config " + formsConfig, + ex); + throw new IllegalStateException("Could not load forms configuration", ex); } } diff --git a/src/main/java/com/condation/cms/modules/forms/FormsTemplateModelExtensionPoint.java b/module/src/main/java/com/condation/cms/modules/forms/FormsTemplateModelExtensionPoint.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/FormsTemplateModelExtensionPoint.java rename to module/src/main/java/com/condation/cms/modules/forms/FormsTemplateModelExtensionPoint.java diff --git a/module/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java b/module/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java new file mode 100644 index 0000000..d0740a7 --- /dev/null +++ b/module/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java @@ -0,0 +1,150 @@ +package com.condation.cms.modules.forms.handler; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +import com.condation.cms.api.extensions.HttpHandler; +import com.condation.cms.api.hooks.HookSystem; +import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.modules.forms.FormsConfig; +import com.condation.cms.modules.forms.FormsFeature; +import com.google.gson.Gson; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.FormFields; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.Fields; +import org.eclipse.jetty.util.Promise; + +public class AjaxSubmitFormHandler implements HttpHandler { + + private static final Gson GSON = new Gson(); + + private final HookSystem hookSystem; + private final SiteModuleContext siteModuleContext; + + public AjaxSubmitFormHandler(final HookSystem hookSystem, final SiteModuleContext siteModuleContext) { + this.hookSystem = hookSystem; + this.siteModuleContext = siteModuleContext; + } + + @Override + public boolean handle(final Request request, final Response response, final Callback callback) { + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "application/json; charset=utf-8"); + response.getHeaders().put(HttpHeader.CACHE_CONTROL, "no-store"); + + if (!"POST".equalsIgnoreCase(request.getMethod())) { + response.getHeaders().put(HttpHeader.ALLOW, "POST"); + write(response, callback, HttpStatus.METHOD_NOT_ALLOWED_405, + new FormResponse(false, "METHOD_NOT_ALLOWED", Map.of())); + return true; + } + + var feature = siteModuleContext.get(FormsFeature.class); + if (!RequestSecurity.isAllowed(request, feature.config().getCsrf())) { + write(response, callback, HttpStatus.FORBIDDEN_403, + new FormResponse(false, "CSRF_REJECTED", Map.of())); + return true; + } + + String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); + if (contentType == null || !MimeTypes.Type.FORM_ENCODED.is(contentType)) { + write(response, callback, HttpStatus.UNSUPPORTED_MEDIA_TYPE_415, + new FormResponse(false, "UNSUPPORTED_MEDIA_TYPE", Map.of())); + return true; + } + + var formHandling = new FormsHandling(hookSystem, siteModuleContext); + FormFields.onFields(request, StandardCharsets.UTF_8, new Promise.Invocable() { + @Override + public void failed(final Throwable failure) { + logger().log(System.Logger.Level.WARNING, "Could not parse AJAX form submission", failure); + write(response, callback, HttpStatus.BAD_REQUEST_400, + new FormResponse(false, "INVALID_REQUEST", Map.of())); + } + + @Override + public void succeeded(final Fields fields) { + FormsConfig.Form form = null; + try { + var formName = value(fields, "form"); + form = feature.config().findForm(formName) + .orElseThrow(() -> new FormHandlingException( + "UNKNOWN_FORM", "unknown form", null, Map.of())); + enforceRateLimit(request, feature, form); + var selectedForm = form; + formHandling.handleForm(selectedForm, name -> value(fields, name)); + write(response, callback, HttpStatus.OK_200, + new FormResponse(true, null, Map.of())); + } catch (FormHandlingException ex) { + logger().log(System.Logger.Level.INFO, "Rejected AJAX form submission: " + ex.getCode()); + int status = "RATE_LIMITED".equals(ex.getCode()) + ? HttpStatus.TOO_MANY_REQUESTS_429 : HttpStatus.BAD_REQUEST_400; + write(response, callback, status, + new FormResponse(false, ex.getCode(), ex.getFieldErrors())); + } catch (RuntimeException ex) { + logger().log(System.Logger.Level.ERROR, "Unexpected AJAX form submission error", ex); + write(response, callback, HttpStatus.INTERNAL_SERVER_ERROR_500, + new FormResponse(false, "INTERNAL_ERROR", Map.of())); + } + } + }); + return true; + } + + private void enforceRateLimit( + final Request request, + final FormsFeature feature, + final FormsConfig.Form form) throws FormHandlingException { + var client = RequestSecurity.clientIdentifier(request); + if (!feature.allow("submit:" + form.getName() + ":" + client, form.getRateLimit())) { + throw new FormHandlingException("RATE_LIMITED", "rate limit exceeded", form, Map.of()); + } + } + + private static String value(final Fields fields, final String name) { + var field = fields.get(name); + return field == null ? null : field.getValue(); + } + + private static void write( + final Response response, + final Callback callback, + final int status, + final FormResponse formResponse) { + response.setStatus(status); + Content.Sink.write(response, true, GSON.toJson(formResponse), callback); + } + + private record FormResponse(boolean success, String code, Map fieldErrors) { + } + + private static System.Logger logger() { + return System.getLogger(AjaxSubmitFormHandler.class.getName()); + } +} diff --git a/src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java b/module/src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java similarity index 72% rename from src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java rename to module/src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java index dbcd7b1..c15c5e2 100644 --- a/src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java +++ b/module/src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java @@ -24,6 +24,7 @@ import com.condation.cms.modules.forms.FormsConfig; +import java.util.Map; import java.util.Optional; /** @@ -33,11 +34,14 @@ public class FormHandlingException extends Exception { private FormsConfig.Form form = null; + private final String code; + private final Map fieldErrors; /** * Creates a new instance of FormHandlingException without detail message. */ public FormHandlingException() { + this("FORM_ERROR", "form handling failed", null, Map.of()); } /** @@ -46,15 +50,33 @@ public FormHandlingException() { * @param msg the detail message. */ public FormHandlingException(String msg) { - super(msg); + this("FORM_ERROR", msg, null, Map.of()); } public FormHandlingException(String msg, final FormsConfig.Form form) { + this("FORM_ERROR", msg, form, Map.of()); + } + + public FormHandlingException( + final String code, + final String msg, + final FormsConfig.Form form, + final Map fieldErrors) { super(msg); this.form = form; + this.code = code; + this.fieldErrors = Map.copyOf(fieldErrors); } public Optional getForm () { return Optional.ofNullable(form); } + + public String getCode() { + return code; + } + + public Map getFieldErrors() { + return fieldErrors; + } } diff --git a/module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java b/module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java new file mode 100644 index 0000000..6dba59c --- /dev/null +++ b/module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java @@ -0,0 +1,205 @@ +package com.condation.cms.modules.forms.handler; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + + +import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.hooks.HookSystem; +import com.condation.cms.api.mail.MailService; +import com.condation.cms.api.mail.Message; +import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.modules.forms.FormsConfig; +import com.condation.cms.modules.forms.FormsFeature; +import com.condation.cms.modules.forms.utils.StringUtil; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * + * @author t.marx + */ +public class FormsHandling { + + private static final Pattern EMAIL = Pattern.compile( + "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); + private static final Set TRUE_VALUES = Set.of("true", "1", "on", "yes"); + private static final Set FALSE_VALUES = Set.of("false", "0", "off", "no"); + + private final HookSystem hookSystem; + + private final SiteModuleContext siteModuleContext; + + public FormsHandling(final HookSystem hookSystem, final SiteModuleContext siteModuleContext) { + this.hookSystem = hookSystem; + this.siteModuleContext = siteModuleContext; + } + + private void validateCaptcha(final FormsConfig.Form form, final String key, final String code) throws FormHandlingException { + var captchas = siteModuleContext.get(FormsFeature.class).captchas(); + var challenge = key == null ? null : captchas.getIfPresent(key); + if (challenge == null || challenge.formName() != null && !challenge.formName().equals(form.getName()) + || code == null || !challenge.answer().equalsIgnoreCase(code.trim())) { + if (challenge != null) { + if (challenge.attempts() >= 4) { + captchas.invalidate(key); + } else { + captchas.put(key, challenge.failedAttempt()); + } + } + throw new FormHandlingException("INVALID_CAPTCHA", "invalid captcha", form, Map.of()); + } + captchas.invalidate(key); + } + + private String buildMessage(final FormsConfig.Form form, final Function parameters) { + StringBuilder message = new StringBuilder(); + + if (form.getFields() != null) { + form.getFields().keySet().forEach(field -> { + var value = parameters.apply(field); + message.append(field).append(":\r\n") + .append(value == null ? "" : value) + .append("\r\n\r\n"); + }); + } + + return message.toString(); + } + + private Map hookData (final FormsConfig.Form form, final Function parameters) { + Map data = new HashMap<>(); + + if (form.getFields() != null) { + form.getFields().keySet().forEach(field -> { + var value = parameters.apply(field); + data.put(field, value); + }); + } + + if (form.getData() != null) { + data.putAll(form.getData()); + } + + return data; + } + + private void validateSpam(final FormsConfig.Form form, final Function parameters) + throws FormHandlingException { + var spam = form.getSpam(); + if (spam != null && spam.getHoneypot() != null && spam.getHoneypot().isEnabled()) { + var value = parameters.apply(spam.getHoneypot().getField()); + if (!StringUtil.isNullOrEmpty(value)) { + throw new FormHandlingException("SPAM_REJECTED", "submission rejected", form, Map.of()); + } + } + } + + private void validateFields(final FormsConfig.Form form, final Function parameters) + throws FormHandlingException { + var errors = new LinkedHashMap(); + form.getFields().forEach((name, definition) -> { + var value = parameters.apply(name); + if (StringUtil.isNullOrEmpty(value)) { + if (definition.isRequired()) { + errors.put(name, "required"); + } + return; + } + + var normalized = value.trim(); + if (definition.getMinLength() != null && normalized.length() < definition.getMinLength()) { + errors.put(name, "min_length"); + } else if (definition.getMaxLength() != null && normalized.length() > definition.getMaxLength()) { + errors.put(name, "max_length"); + } else if ("email".equals(definition.getType()) && !EMAIL.matcher(normalized).matches()) { + errors.put(name, "invalid_email"); + } else if ("integer".equals(definition.getType())) { + try { + Long.valueOf(normalized); + } catch (NumberFormatException ex) { + errors.put(name, "invalid_integer"); + } + } else if ("boolean".equals(definition.getType()) + && !TRUE_VALUES.contains(normalized.toLowerCase()) + && !FALSE_VALUES.contains(normalized.toLowerCase())) { + errors.put(name, "invalid_boolean"); + } else if (definition.getPattern() != null + && !Pattern.compile(definition.getPattern()).matcher(value).matches()) { + errors.put(name, "pattern"); + } else if (definition.getAllowedValues() != null + && !definition.getAllowedValues().isEmpty() + && !definition.getAllowedValues().contains(value)) { + errors.put(name, "not_allowed"); + } + }); + if (!errors.isEmpty()) { + throw new FormHandlingException( + "VALIDATION_FAILED", "field validation failed", form, errors); + } + } + + public void handleForm(final FormsConfig.Form form, final Function parameters) throws FormHandlingException { + validateSpam(form, parameters); + validateFields(form, parameters); + if (form.getCaptcha().isEnabled()) { + validateCaptcha(form, parameters.apply("key"), parameters.apply("code")); + } + + try { + var data = hookData(form, parameters); + data.put("form", form.getName()); + hookSystem.doAction( + "forms/%s/submit".formatted(form.getName()), + data); + + if (StringUtil.isNullOrEmpty(form.getTo())) { + return; + } + + var mailService = siteModuleContext.get(InjectorFeature.class).injector().getInstance(MailService.class); + + var message = new Message( + form.getMail().getFrom(), + new com.condation.cms.api.mail.Message.Recipient("", form.getTo()), + sanitizeHeader(form.getSubject()), + buildMessage(form, parameters) + ); + + mailService.sendText(form.getMail().getAccount(), message); + } catch (Exception e) { + System.getLogger(getClass().getName()).log( + System.Logger.Level.ERROR, + "Actions failed for form " + form.getName(), + e); + throw new FormHandlingException("ACTION_FAILED", "form actions failed", form, Map.of()); + } + } + + private String sanitizeHeader(final String value) { + return value == null ? "" : value.replace("\r", "").replace("\n", ""); + } +} diff --git a/src/main/java/com/condation/cms/modules/forms/handler/GenerateCaptchaHandler.java b/module/src/main/java/com/condation/cms/modules/forms/handler/GenerateCaptchaHandler.java similarity index 56% rename from src/main/java/com/condation/cms/modules/forms/handler/GenerateCaptchaHandler.java rename to module/src/main/java/com/condation/cms/modules/forms/handler/GenerateCaptchaHandler.java index ca5af3f..288083f 100644 --- a/src/main/java/com/condation/cms/modules/forms/handler/GenerateCaptchaHandler.java +++ b/module/src/main/java/com/condation/cms/modules/forms/handler/GenerateCaptchaHandler.java @@ -24,8 +24,9 @@ import com.condation.cms.api.extensions.HttpHandler; +import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.utils.HTTPUtil; -import com.condation.cms.modules.forms.FormsLifecycleExtension; +import com.condation.cms.modules.forms.FormsFeature; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.util.List; @@ -34,6 +35,8 @@ import net.logicsquad.nanocaptcha.image.ImageCaptcha; import net.logicsquad.nanocaptcha.image.filter.StretchImageFilter; import net.logicsquad.nanocaptcha.image.noise.StraightLineNoiseProducer; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.io.Content; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; @@ -46,7 +49,13 @@ public class GenerateCaptchaHandler implements HttpHandler { private static final int DEFAULT_CAPTCHA_WIDTH = 250; - private static final int DEFAULT_CAPTCHA_HEIGHT = 250; + private static final int DEFAULT_CAPTCHA_HEIGHT = 80; + private static final int MIN_CAPTCHA_SIZE = 32; + private final SiteModuleContext siteModuleContext; + + public GenerateCaptchaHandler(final SiteModuleContext siteModuleContext) { + this.siteModuleContext = siteModuleContext; + } @Override public boolean handle(Request request, Response response, Callback callback) throws Exception { @@ -55,18 +64,35 @@ public boolean handle(Request request, Response response, Callback callback) thr int width = getSizeParam("width", queryParameters, DEFAULT_CAPTCHA_WIDTH); int height = getSizeParam("height", queryParameters, DEFAULT_CAPTCHA_HEIGHT); - - String key = queryParameters.getOrDefault("key", List.of("default")).get(0); + + String key = first(queryParameters, "key"); + String formName = first(queryParameters, "form"); + var feature = siteModuleContext.get(FormsFeature.class); + if (!validKey(key) || feature.config().findForm(formName).isEmpty()) { + Response.writeError(request, response, callback, HttpStatus.BAD_REQUEST_400, "invalid captcha request"); + return true; + } + String client = RequestSecurity.clientIdentifier(request); + if (!feature.allow("captcha:" + client, feature.config().getCaptchaRateLimit())) { + response.getHeaders().put(HttpHeader.CACHE_CONTROL, "no-store"); + Response.writeError(request, response, callback, HttpStatus.TOO_MANY_REQUESTS_429, "rate limit exceeded"); + return true; + } ImageCaptcha imageCaptcha = new ImageCaptcha.Builder(width, height).addContent() .addFilter(new StretchImageFilter()) .addNoise(new StraightLineNoiseProducer()) .build(); - FormsLifecycleExtension.CAPTCHAS.put(key, imageCaptcha.getContent()); + feature.captchas().put( + key, + new FormsFeature.CaptchaChallenge(imageCaptcha.getContent(), formName, 0)); ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(imageCaptcha.getImage(), "PNG", baos); byte[] bytes = baos.toByteArray(); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "image/png"); + response.getHeaders().put(HttpHeader.CACHE_CONTROL, "no-store, no-cache, must-revalidate"); + response.getHeaders().put("Pragma", "no-cache"); Content.Sink.write(response, true, ByteBuffer.wrap(bytes)); callback.succeeded(); @@ -75,13 +101,22 @@ public boolean handle(Request request, Response response, Callback callback) thr private int getSizeParam (final String name, Map> queryParameters, final int defaultValue) { String sizeParam = queryParameters.getOrDefault(name, List.of(String.valueOf(defaultValue))).get(0); - - int intValue = Integer.parseInt(sizeParam.trim()); - if (intValue > defaultValue) { + + try { + int intValue = Integer.parseInt(sizeParam.trim()); + return Math.clamp(intValue, MIN_CAPTCHA_SIZE, defaultValue); + } catch (NumberFormatException ex) { return defaultValue; - } else { - return intValue; } } + private String first(final Map> parameters, final String name) { + var values = parameters.get(name); + return values == null || values.isEmpty() ? null : values.getFirst(); + } + + private boolean validKey(final String key) { + return key != null && key.length() >= 32 && key.length() <= 128 + && key.matches("[A-Za-z0-9_-]+"); + } } diff --git a/module/src/main/java/com/condation/cms/modules/forms/handler/RequestSecurity.java b/module/src/main/java/com/condation/cms/modules/forms/handler/RequestSecurity.java new file mode 100644 index 0000000..95e0939 --- /dev/null +++ b/module/src/main/java/com/condation/cms/modules/forms/handler/RequestSecurity.java @@ -0,0 +1,79 @@ +package com.condation.cms.modules.forms.handler; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +import com.condation.cms.modules.forms.FormsConfig; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.Locale; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.server.Request; + +final class RequestSecurity { + + private RequestSecurity() { + } + + static boolean isAllowed(final Request request, final FormsConfig.Csrf policy) { + if (policy == null || !policy.isEnabled()) { + return true; + } + + String origin = request.getHeaders().get(HttpHeader.ORIGIN); + if (origin != null && policy.getAllowedOrigins() != null + && policy.getAllowedOrigins().contains(origin)) { + return true; + } + + String fetchSite = request.getHeaders().get("Sec-Fetch-Site"); + if ("cross-site".equalsIgnoreCase(fetchSite)) { + return false; + } + if (origin == null) { + return true; + } + + String host = request.getHeaders().get(HttpHeader.HOST); + if (host == null) { + return false; + } + try { + var originUri = URI.create(origin); + return originUri.getRawAuthority() != null + && originUri.getRawAuthority().toLowerCase(Locale.ROOT) + .equals(host.toLowerCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + return false; + } + } + + static String clientIdentifier(final Request request) { + var remote = request.getConnectionMetaData().getRemoteSocketAddress(); + if (remote instanceof InetSocketAddress inet) { + return inet.getAddress() == null + ? inet.getHostString() + : inet.getAddress().getHostAddress(); + } + return String.valueOf(remote); + } +} diff --git a/module/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java b/module/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java new file mode 100644 index 0000000..ad5a310 --- /dev/null +++ b/module/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java @@ -0,0 +1,134 @@ +package com.condation.cms.modules.forms.handler; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +import com.condation.cms.api.extensions.HttpHandler; +import com.condation.cms.api.hooks.HookSystem; +import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.modules.forms.FormsConfig; +import com.condation.cms.modules.forms.FormsFeature; +import java.nio.charset.StandardCharsets; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.server.FormFields; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.Fields; +import org.eclipse.jetty.util.Promise; + +/** + * Handles browser form submissions. Uploads are deliberately rejected until a + * bounded and validated upload policy exists. + */ +public class SubmitFormHandler implements HttpHandler { + + private final HookSystem hookSystem; + private final SiteModuleContext siteModuleContext; + + public SubmitFormHandler(final HookSystem hookSystem, final SiteModuleContext siteModuleContext) { + this.hookSystem = hookSystem; + this.siteModuleContext = siteModuleContext; + } + + @Override + public boolean handle(final Request request, final Response response, final Callback callback) { + if (!"POST".equalsIgnoreCase(request.getMethod())) { + response.getHeaders().put(HttpHeader.ALLOW, "POST"); + Response.writeError(request, response, callback, HttpStatus.METHOD_NOT_ALLOWED_405, "invalid request"); + return true; + } + + var feature = siteModuleContext.get(FormsFeature.class); + if (!RequestSecurity.isAllowed(request, feature.config().getCsrf())) { + redirect(response, callback, feature.config().errorRedirect(null)); + return true; + } + String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); + if (contentType == null || !MimeTypes.Type.FORM_ENCODED.is(contentType)) { + redirect(response, callback, feature.config().errorRedirect(null)); + return true; + } + + var formHandling = new FormsHandling(hookSystem, siteModuleContext); + FormFields.onFields(request, StandardCharsets.UTF_8, new Promise.Invocable() { + @Override + public void failed(final Throwable failure) { + logger().log(System.Logger.Level.WARNING, "Could not parse form submission", failure); + redirect(response, callback, feature.config().errorRedirect(null)); + } + + @Override + public void succeeded(final Fields fields) { + FormsConfig.Form form = null; + try { + var formName = value(fields, "form"); + form = feature.config().findForm(formName) + .orElseThrow(() -> new FormHandlingException( + "UNKNOWN_FORM", "unknown form", null, java.util.Map.of())); + enforceRateLimit(request, feature, form); + var selectedForm = form; + formHandling.handleForm(selectedForm, name -> value(fields, name)); + redirect(response, callback, feature.config().successRedirect(selectedForm)); + } catch (FormHandlingException ex) { + logger().log(System.Logger.Level.INFO, "Rejected form submission: " + ex.getCode()); + redirect(response, callback, feature.config().errorRedirect(form)); + } catch (RuntimeException ex) { + logger().log(System.Logger.Level.ERROR, "Unexpected form submission error", ex); + redirect(response, callback, feature.config().errorRedirect(form)); + } + } + }); + return true; + } + + private void enforceRateLimit( + final Request request, + final FormsFeature feature, + final FormsConfig.Form form) throws FormHandlingException { + var client = RequestSecurity.clientIdentifier(request); + if (!feature.allow("submit:" + form.getName() + ":" + client, form.getRateLimit())) { + throw new FormHandlingException( + "RATE_LIMITED", "rate limit exceeded", form, java.util.Map.of()); + } + } + + private static String value(final Fields fields, final String name) { + var field = fields.get(name); + return field == null ? null : field.getValue(); + } + + private static void redirect( + final Response response, + final Callback callback, + final String location) { + response.getHeaders().put(HttpHeader.LOCATION, location); + response.setStatus(HttpStatus.SEE_OTHER_303); + callback.succeeded(); + } + + private static System.Logger logger() { + return System.getLogger(SubmitFormHandler.class.getName()); + } +} diff --git a/src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java b/module/src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java similarity index 97% rename from src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java rename to module/src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java index e585b99..82c2692 100644 --- a/src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java +++ b/module/src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java @@ -37,7 +37,7 @@ public Captcha getCaptcha () { return captcha; } - public class Captcha { + public static class Captcha { public String generateKey () { return StringUtil.random_string(); } diff --git a/src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java b/module/src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java similarity index 70% rename from src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java rename to module/src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java index 3f84ce2..f163856 100644 --- a/src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java +++ b/module/src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java @@ -23,7 +23,8 @@ */ -import java.util.Random; +import java.security.SecureRandom; +import java.util.Base64; /** * @@ -31,18 +32,12 @@ */ public class StringUtil { - static Random random = new Random(); + private static final SecureRandom RANDOM = new SecureRandom(); public static String random_string() { - int leftLimit = 48; // numeral '0' - int rightLimit = 122; // letter 'z' - int targetStringLength = 10; - - return random.ints(leftLimit, rightLimit + 1) - .filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97)) - .limit(targetStringLength) - .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) - .toString(); + var bytes = new byte[24]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } public static boolean isNullOrEmpty (String value) { diff --git a/src/test/java/com/condation/cms/modules/forms/CaptchaTest.java b/module/src/test/java/com/condation/cms/modules/forms/CaptchaTest.java similarity index 100% rename from src/test/java/com/condation/cms/modules/forms/CaptchaTest.java rename to module/src/test/java/com/condation/cms/modules/forms/CaptchaTest.java diff --git a/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java b/module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java similarity index 60% rename from src/test/java/com/condation/cms/modules/forms/FormConfigTest.java rename to module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java index d774c30..9c5ce44 100644 --- a/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java +++ b/module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java @@ -39,11 +39,36 @@ public class FormConfigTest { void test_config () throws Exception { var FORMSCONFIG = new Yaml().loadAs( Files.readString(Path.of("src/test/resources/config/forms.yaml"), StandardCharsets.UTF_8), FormsConfig.class); + FORMSCONFIG.validate(); Assertions.assertThat(FORMSCONFIG.findForm("contact")).isPresent(); Assertions.assertThat(FORMSCONFIG.findForm("test-form")).isPresent(); + Assertions.assertThat(FORMSCONFIG.findForm("missing")).isEmpty(); Assertions.assertThat(FORMSCONFIG.findForm("contact").get().getMail().getAccount()).isEqualTo("default"); + Assertions.assertThat(FORMSCONFIG.findForm("contact").get().getMail().getFrom()).isEqualTo("forms@example.com"); + Assertions.assertThat(FORMSCONFIG.findForm("contact").get().getFields().get("from").getType()).isEqualTo("email"); + Assertions.assertThat(FORMSCONFIG.findForm("contact").get().getFields().get("message").getMinLength()).isEqualTo(10); Assertions.assertThat(FORMSCONFIG.findForm("test-form").get().getMail().getAccount()).isEqualTo("other"); } + + @Test + void captcha_defaults_to_enabled_and_can_be_disabled() throws Exception { + var yaml = """ + forms: + - name: with-default + fields: + message: {} + - name: without-captcha + captcha: + enabled: false + fields: + message: {} + """; + var config = new org.yaml.snakeyaml.Yaml().loadAs(yaml, FormsConfig.class); + config.validate(); + + Assertions.assertThat(config.findForm("with-default").get().getCaptcha().isEnabled()).isTrue(); + Assertions.assertThat(config.findForm("without-captcha").get().getCaptcha().isEnabled()).isFalse(); + } } diff --git a/module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java b/module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java new file mode 100644 index 0000000..ffafbe7 --- /dev/null +++ b/module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java @@ -0,0 +1,126 @@ +package com.condation.cms.modules.forms; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +import com.condation.cms.api.hooks.HookSystem; +import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.modules.forms.handler.FormHandlingException; +import com.condation.cms.modules.forms.handler.FormsHandling; +import java.lang.reflect.Proxy; +import java.util.LinkedHashMap; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FormsHandlingTest { + + private FormsConfig.Form form; + private FormsFeature feature; + private FormsHandling handling; + + @BeforeEach + void setUp() { + form = new FormsConfig.Form(); + form.setName("contact"); + var email = new FormsConfig.Field(); + email.setType("email"); + email.setRequired(true); + form.setFields(Map.of("email", email)); + + var config = new FormsConfig(); + config.setForms(java.util.List.of(form)); + feature = new FormsFeature(config); + + var context = new SiteModuleContext(); + context.add(FormsFeature.class, feature); + var hooks = (HookSystem) Proxy.newProxyInstance( + HookSystem.class.getClassLoader(), + new Class[]{HookSystem.class}, + (proxy, method, arguments) -> method.getName().equals("doAction") + ? java.util.List.of() : null); + handling = new FormsHandling(hooks, context); + } + + @Test + void rejectsSubmittedCaptchaCodeInsteadOfComparingStoredValueWithItself() { + feature.captchas().put("key", new FormsFeature.CaptchaChallenge("correct", "contact", 0)); + var values = validValues(); + values.put("code", "wrong"); + + Assertions.assertThatThrownBy(() -> handling.handleForm(form, values::get)) + .isInstanceOf(FormHandlingException.class) + .extracting("code") + .isEqualTo("INVALID_CAPTCHA"); + } + + @Test + void acceptsAndConsumesCorrectCaptcha() throws Exception { + feature.captchas().put("key", new FormsFeature.CaptchaChallenge("correct", "contact", 0)); + handling.handleForm(form, validValues()::get); + + Assertions.assertThat(feature.captchas().getIfPresent("key")).isNull(); + } + + @Test + void reportsMissingRequiredFields() { + feature.captchas().put("key", new FormsFeature.CaptchaChallenge("correct", "contact", 0)); + var values = validValues(); + values.remove("email"); + + Assertions.assertThatThrownBy(() -> handling.handleForm(form, values::get)) + .isInstanceOf(FormHandlingException.class) + .satisfies(ex -> Assertions.assertThat(((FormHandlingException) ex).getFieldErrors()) + .containsEntry("email", "required")); + } + + @Test + void rejectsFilledHoneypot() { + form.getSpam().getHoneypot().setEnabled(true); + var values = validValues(); + values.put("website", "https://spam.example"); + + Assertions.assertThatThrownBy(() -> handling.handleForm(form, values::get)) + .isInstanceOf(FormHandlingException.class) + .extracting("code") + .isEqualTo("SPAM_REJECTED"); + } + + @Test + void skipsCaptchaValidationWhenDisabled() throws Exception { + form.getCaptcha().setEnabled(false); + var values = validValues(); + values.remove("key"); + values.remove("code"); + + handling.handleForm(form, values::get); + } + + private Map validValues() { + var values = new LinkedHashMap(); + values.put("email", "visitor@example.com"); + values.put("key", "key"); + values.put("code", "correct"); + return values; + } +} diff --git a/module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java b/module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java new file mode 100644 index 0000000..4af09b3 --- /dev/null +++ b/module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java @@ -0,0 +1,174 @@ +package com.condation.cms.modules.forms.e2e; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +import com.condation.cms.cli.tools.CLIServerUtils; +import com.condation.cms.test.e2e.CMSServerExtension; +import com.icegreen.greenmail.configuration.GreenMailConfiguration; +import com.icegreen.greenmail.junit5.GreenMailExtension; +import com.icegreen.greenmail.util.ServerSetup; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.junit.UsePlaywright; +import jakarta.mail.internet.MimeMessage; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; + +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * + * @author thorstenmarx + */ +@UsePlaywright +public class E2EIT { + + private static final String SMTP_HOST = "127.0.0.1"; + private static final int SMTP_PORT = 3025; + private static final String SMTP_USERNAME = "test@example.test"; + private static final String SMTP_PASSWORD = "password"; + private static final String BASE_URL = "http://localhost:2020"; + private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); + + @RegisterExtension + @Order(1) + static final GreenMailExtension GREEN_MAIL = new GreenMailExtension( + new ServerSetup(SMTP_PORT, SMTP_HOST, ServerSetup.PROTOCOL_SMTP)) + .withConfiguration(GreenMailConfiguration.aConfig() + .withUser(SMTP_USERNAME, SMTP_USERNAME, SMTP_PASSWORD)) + .withPerMethodLifecycle(false); + + @RegisterExtension + @Order(2) + static final CMSServerExtension SERVER = new CMSServerExtension("../test-server"); + + @BeforeEach + void resetMailServer() throws Exception { + GREEN_MAIL.purgeEmailFromAllMailboxes(); + } + + @Test + void server_is_started() throws Exception { + Assertions.assertThat(CLIServerUtils.getCMSProcess()).isPresent(); + } + + @Test + void start_page(Page page) { + page.navigate("http://localhost:2020"); + Assertions.assertThat(page.title()).isEqualTo("forms test site"); + } + + @Test + void mail_form_is_rendered(Page page) { + page.navigate("http://localhost:2020/forms/mail"); + + Assertions.assertThat(page.title()).isEqualTo("Mail form test"); + Assertions.assertThat(page.locator("#mail-form").count()).isEqualTo(1); + Assertions.assertThat(page.locator("input[name=form]").inputValue()).isEqualTo("mail"); + } + + @Test + void valid_form_sends_mail(Page page) throws Exception { + page.navigate("http://localhost:2020/forms/mail"); + page.locator("#mail-email").fill("visitor@example.test"); + page.locator("#mail-message").fill("This message was submitted by the E2E test."); + page.locator("#mail-submit").click(); + + page.waitForURL("**/forms/mail-success"); + Assertions.assertThat(page.locator("#result").innerText()).isEqualTo("mail-success"); + Assertions.assertThat(GREEN_MAIL.waitForIncomingEmail(5_000, 1)).isTrue(); + + MimeMessage message = GREEN_MAIL.getReceivedMessages()[0]; + Assertions.assertThat(message.getSubject()).isEqualTo("Forms E2E mail"); + Assertions.assertThat(message.getAllRecipients()) + .extracting(Object::toString) + .containsExactly("recipient@example.test"); + Assertions.assertThat(message.getFrom()) + .extracting(Object::toString) + .containsExactly("Forms E2E test "); + Assertions.assertThat(message.getContent().toString()) + .contains("email:", "visitor@example.test") + .contains("message:", "This message was submitted by the E2E test."); + } + + @Test + void invalid_form_redirects_to_its_error_page(Page page) { + page.navigate("http://localhost:2020/forms/validation"); + page.locator("#validation-email").fill("not-an-email"); + page.locator("#validation-message").fill("short"); + page.locator("#validation-submit").click(); + + page.waitForURL("**/forms/validation-error"); + Assertions.assertThat(page.locator("#result").innerText()).isEqualTo("validation-error"); + Assertions.assertThat(GREEN_MAIL.getReceivedMessages()).isEmpty(); + } + + @Test + void security_features_reject_cross_site_spam_and_excess_requests(Page page) throws Exception { + page.navigate(BASE_URL + "/forms/security"); + Assertions.assertThat(page.title()).isEqualTo("Security form test"); + Assertions.assertThat(page.locator("#security-form").count()).isEqualTo(1); + Assertions.assertThat(page.locator("input[name=website]").count()).isEqualTo(1); + + var crossSite = submitSecurityForm( + "https://attacker.example", + "form=security&message=Cross-site+submission"); + Assertions.assertThat(crossSite.statusCode()).isEqualTo(403); + Assertions.assertThat(crossSite.body()).contains("\"code\":\"CSRF_REJECTED\""); + + var honeypot = submitSecurityForm( + BASE_URL, + "form=security&message=Automated+submission&website=https%3A%2F%2Fspam.example"); + Assertions.assertThat(honeypot.statusCode()).isEqualTo(400); + Assertions.assertThat(honeypot.body()).contains("\"code\":\"SPAM_REJECTED\""); + + var valid = submitSecurityForm( + BASE_URL, + "form=security&message=Allowed+submission"); + Assertions.assertThat(valid.statusCode()).isEqualTo(200); + Assertions.assertThat(valid.body()).contains("\"success\":true"); + + var rateLimited = submitSecurityForm( + BASE_URL, + "form=security&message=One+request+too+many"); + Assertions.assertThat(rateLimited.statusCode()).isEqualTo(429); + Assertions.assertThat(rateLimited.body()).contains("\"code\":\"RATE_LIMITED\""); + } + + private HttpResponse submitSecurityForm( + final String origin, + final String formBody) throws Exception { + var request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/module/forms-module/form/submit/ajax")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Origin", origin) + .POST(HttpRequest.BodyPublishers.ofString(formBody)) + .build(); + return HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/src/test/resources/config/forms.yaml b/module/src/test/resources/config/forms.yaml similarity index 51% rename from src/test/resources/config/forms.yaml rename to module/src/test/resources/config/forms.yaml index 2585c24..99c372a 100644 --- a/src/test/resources/config/forms.yaml +++ b/module/src/test/resources/config/forms.yaml @@ -2,13 +2,24 @@ forms: - name: contact to: contact@example.com subject: Ich suche Kontakt! - fields: [message] + fields: + from: + type: email + required: true + message: + required: true + minLength: 10 + maxLength: 5000 + mail: + from: forms@example.com redirects: success: /forms/contact/success - name: test-form + fields: + message: {} redirects: success: /forms/contact/success mail: account: other redirects: - error: /forms/error \ No newline at end of file + error: /forms/error diff --git a/pom.xml b/pom.xml index 76a6645..243dbd6 100644 --- a/pom.xml +++ b/pom.xml @@ -4,9 +4,12 @@ com.condation.cms.modules - forms-module + forms-module-parent 4.1.0 - jar + pom + + module + UTF-8 @@ -30,100 +33,92 @@ https://www.gnu.org/licenses/gpl-3.0.html - + - - net.logicsquad - nanocaptcha - 2.1 - - - org.slf4j - slf4j-api - - - - - com.github.ben-manes.caffeine - caffeine - 3.2.4 - - - org.yaml - snakeyaml - 2.6 - - - com.google.code.gson - gson - 2.14.0 - + - - org.junit.jupiter - junit-jupiter - 6.1.3 - test - - - com.icegreen - greenmail-junit5 - 2.1.12 - test - - - org.slf4j - slf4j-api - - - - - org.assertj - assertj-core - 3.27.7 - test - + + net.logicsquad + nanocaptcha + 2.1 + + + org.slf4j + slf4j-api + + + + + com.github.ben-manes.caffeine + caffeine + 3.2.4 + + + org.yaml + snakeyaml + 2.6 + + + com.google.code.gson + gson + 2.14.0 + + + + org.junit.jupiter + junit-jupiter + 6.1.2 + test + + + com.icegreen + greenmail-junit5 + 2.1.11 + test + + + org.slf4j + slf4j-api + + + + + org.assertj + assertj-core + 3.27.7 + + + + com.condation.cms + cms-test-server + ${cms.version} + + + com.microsoft.playwright + playwright + 1.60.0 + - - com.condation.cms - cms-api - ${cms.version} - provided - - - com.condation.modules.framework - modules-api - ${modules.version} - provided - - - org.projectlombok - lombok - ${lombok.version} - provided - - + + com.condation.cms + cms-api + ${cms.version} + + + com.condation.modules.framework + modules-api + ${modules.version} + + + org.projectlombok + lombok + ${lombok.version} + + + - - maven-assembly-plugin - 3.8.0 - - - src/main/assembly/assembly.xml - - ${module.id} - - - - package - - single - - - - org.codehaus.mojo license-maven-plugin @@ -155,6 +150,7 @@ maven-compiler-plugin 3.15.0 + full org.projectlombok @@ -168,7 +164,7 @@ - + diff --git a/src/main/java/com/condation/cms/modules/forms/FormsConfig.java b/src/main/java/com/condation/cms/modules/forms/FormsConfig.java deleted file mode 100644 index f9b95f3..0000000 --- a/src/main/java/com/condation/cms/modules/forms/FormsConfig.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.condation.cms.modules.forms; - -/*- - * #%L - * forms-module - * %% - * Copyright (C) 2024 CondationCMS - * %% - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public - * License along with this program. If not, see - * . - * #L% - */ - - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import lombok.Data; - -/** - * - * @author t.marx - */ -@Data -public class FormsConfig { - - private List forms; - - private Redirects redirects; - - public Optional findForm (final String name) { - return forms.stream().filter(form -> form.getName().equals(name)).findFirst(); - } - - @Data - public static class Form { - private String name; - private Redirects redirects; - private List fields; - private String to; - private String subject; - private Map data; - - private Mail mail = new Mail(); - } - - @Data - public static class Redirects { - private String error; - private String success; - } - - @Data - public static class Mail { - private String account = "default"; - - } -} diff --git a/src/main/java/com/condation/cms/modules/forms/handler/AjaxCaptchaValidationHandler.java b/src/main/java/com/condation/cms/modules/forms/handler/AjaxCaptchaValidationHandler.java deleted file mode 100644 index 4a7e809..0000000 --- a/src/main/java/com/condation/cms/modules/forms/handler/AjaxCaptchaValidationHandler.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.condation.cms.modules.forms.handler; - -/*- - * #%L - * forms-module - * %% - * Copyright (C) 2024 CondationCMS - * %% - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public - * License along with this program. If not, see - * . - * #L% - */ - - -import com.condation.cms.api.extensions.HttpHandler; -import com.condation.cms.modules.forms.FormsLifecycleExtension; -import com.google.gson.Gson; -import java.nio.charset.StandardCharsets; -import lombok.extern.slf4j.Slf4j; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.io.Content; -import org.eclipse.jetty.server.Request; -import org.eclipse.jetty.server.Response; -import org.eclipse.jetty.util.Callback; - -/** - * - * @author t.marx - */ -@Slf4j -public class AjaxCaptchaValidationHandler implements HttpHandler { - - private static final Gson GSON = new Gson(); - - @Override - public boolean handle(Request request, Response response, Callback callback) throws Exception { - - response.getHeaders().add(HttpHeader.CONTENT_TYPE, "application/json"); - - if (!"POST".equalsIgnoreCase(request.getMethod())) { - response.setStatus(405); - callback.succeeded(); - return true; - } - - String body = readBody(request); - var formData = GSON.fromJson(body, FormsData.class); - - boolean valid = false; - String captchaCode = FormsLifecycleExtension.CAPTCHAS.getIfPresent(formData.key()); - if (captchaCode != null && captchaCode.equals(formData.code())) { - valid = true; - } - - Content.Sink.write(response, true, GSON.toJson(new ValidationResponse(valid)), callback); - - return true; - } - - private String readBody(final Request request) { - try (var inputStream = Request.asInputStream(request)) { - return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - } catch (Exception ex) { - log.error("", ex); - } - return ""; - } - - public record FormsData(String code, String key) {} - public record ValidationResponse (boolean valid) {} -} diff --git a/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java b/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java deleted file mode 100644 index 5c32778..0000000 --- a/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.condation.cms.modules.forms.handler; - -/*- - * #%L - * forms-module - * %% - * Copyright (C) 2024 CondationCMS - * %% - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public - * License along with this program. If not, see - * . - * #L% - */ -import com.condation.cms.api.extensions.HttpHandler; -import com.condation.cms.api.hooks.HookSystem; -import com.condation.cms.api.module.SiteModuleContext; -import com.condation.cms.modules.forms.FormsLifecycleExtension; -import com.google.gson.Gson; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.http.HttpStatus; -import org.eclipse.jetty.http.MimeTypes; -import org.eclipse.jetty.http.MultiPart; -import org.eclipse.jetty.http.MultiPartFormData; -import org.eclipse.jetty.io.Content; -import org.eclipse.jetty.server.FormFields; -import org.eclipse.jetty.server.Request; -import org.eclipse.jetty.server.Response; -import org.eclipse.jetty.util.Callback; -import org.eclipse.jetty.util.Fields; -import org.eclipse.jetty.util.Promise; - -/** - * - * @author t.marx - */ -@Slf4j -@RequiredArgsConstructor -public class AjaxSubmitFormHandler implements HttpHandler { - - private final static Gson GSON = new Gson(); - - private final HookSystem hookSystem; - - private final SiteModuleContext siteModuleContext; - - @Override - public boolean handle(Request request, Response response, Callback callback) throws Exception { - - String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); - response.getHeaders().add(HttpHeader.CONTENT_TYPE, "application/json"); - - FormsHandling formHandling = new FormsHandling(hookSystem, siteModuleContext); - - try { - if (MimeTypes.Type.FORM_ENCODED.is(contentType)) { - FormFields.onFields(request, StandardCharsets.UTF_8, new Promise.Invocable() { - @Override - public void succeeded(Fields fields) { - FormResponse formResponse; - try { - final String formName = fields.get("form").getValue(); - var form = FormsLifecycleExtension.FORMSCONFIG.findForm(formName).get(); - formHandling.handleForm(form, (field) -> { - if (fields.get(field) != null) { - return fields.get(field).getValue(); - } - return field; - }); - formResponse = new FormResponse(false); - response.setStatus(HttpStatus.OK_200); - } catch (FormHandlingException fhe) { - log.error(null, fhe); - formResponse = new FormResponse(true); - response.setStatus(HttpStatus.BAD_REQUEST_400); - } - Content.Sink.write(response, true, GSON.toJson(formResponse), callback); - } - - @Override - public void failed(Throwable x) { - var formResponse = new FormResponse(true); - response.setStatus(HttpStatus.BAD_REQUEST_400); - Content.Sink.write(response, true, GSON.toJson(formResponse), callback); - } - }); - } else if (contentType.startsWith(MimeTypes.Type.MULTIPART_FORM_DATA.asString())) { - String boundary = MultiPart.extractBoundary(contentType); - MultiPartFormData.Parser parser = new MultiPartFormData.Parser(boundary); - parser.setFilesDirectory(Files.createTempDirectory("cms-upload")); - - parser.parse(request, new Promise.Invocable() { - @Override - public void failed(Throwable x) { - var formResponse = new FormResponse(true); - response.setStatus(HttpStatus.BAD_REQUEST_400); - Content.Sink.write(response, true, GSON.toJson(formResponse), callback); - } - - @Override - public void succeeded(MultiPartFormData.Parts parts) { - FormResponse formResponse; - try { - - String formName = parts.getFirst("form").getContentAsString(StandardCharsets.UTF_8); - var form = FormsLifecycleExtension.FORMSCONFIG.findForm(formName).get(); - formHandling.handleForm(form, (field) -> { - if (parts.getAll(field) != null && !parts.getAll(field).isEmpty()) { - return parts.getAll(field).getFirst().getContentAsString(StandardCharsets.UTF_8); - } - return field; - }); - - formResponse = new FormResponse(false); - response.setStatus(HttpStatus.OK_200); - } catch (FormHandlingException fhe) { - log.error(null, fhe); - formResponse = new FormResponse(true); - response.setStatus(HttpStatus.BAD_REQUEST_400); - } - Content.Sink.write(response, true, GSON.toJson(formResponse), callback); - } - - }); - } else { - var formResponse = new FormResponse(true); - response.setStatus(HttpStatus.BAD_REQUEST_400); - Content.Sink.write(response, true, GSON.toJson(formResponse), callback); - } - } catch (Exception e) { - log.error("error processing form", e); - var formResponse = new FormResponse(true); - response.setStatus(HttpStatus.BAD_REQUEST_400); - Content.Sink.write(response, true, GSON.toJson(formResponse), callback); - } - - return true; - } - - private static record FormResponse(boolean error) { - - } -; -} diff --git a/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java b/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java deleted file mode 100644 index e291b68..0000000 --- a/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.condation.cms.modules.forms.handler; - -/*- - * #%L - * forms-module - * %% - * Copyright (C) 2024 CondationCMS - * %% - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public - * License along with this program. If not, see - * . - * #L% - */ - - -import com.condation.cms.api.feature.features.InjectorFeature; -import com.condation.cms.api.hooks.HookSystem; -import com.condation.cms.api.mail.MailService; -import com.condation.cms.api.mail.Message; -import com.condation.cms.api.module.SiteModuleContext; -import com.condation.cms.modules.forms.FormsConfig; -import com.condation.cms.modules.forms.FormsLifecycleExtension; -import com.condation.cms.modules.forms.utils.StringUtil; -import java.util.HashMap; -import java.util.Map; -import java.util.function.Function; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * - * @author t.marx - */ -@RequiredArgsConstructor -@Slf4j -public class FormsHandling { - - private final HookSystem hookSystem; - - private final SiteModuleContext siteModuleContext; - - private void validateCaptcha(final FormsConfig.Form form, final String key, final String code) throws FormHandlingException { - String captchaCode = FormsLifecycleExtension.CAPTCHAS.getIfPresent(key); - if (captchaCode == null || !captchaCode.equals(code)) { - throw new FormHandlingException("invalid captcha", form); - } - - } - - private String buildMessage(final FormsConfig.Form form, final Function parameters) { - StringBuilder message = new StringBuilder(); - - if (form.getFields() != null) { - form.getFields().forEach(field -> { - var value = parameters.apply(field); - message.append("field: ").append(field).append("\r\n").append(value); - }); - } - - return message.toString(); - } - - private Map hookData (final FormsConfig.Form form, final Function parameters) { - Map data = new HashMap<>(); - - if (form.getFields() != null) { - form.getFields().forEach(field -> { - var value = parameters.apply(field); - data.put(field, value); - }); - } - - if (form.getData() != null) { - data.putAll(form.getData()); - } - - return data; - } - - public void handleForm(final FormsConfig.Form form, final Function parameters) throws FormHandlingException { - try { - final String key = parameters.apply("key"); - String captchaCode = FormsLifecycleExtension.CAPTCHAS.getIfPresent(key); - - validateCaptcha(form, key, captchaCode); - FormsLifecycleExtension.CAPTCHAS.invalidate(key); - - var data = hookData(form, parameters); - data.put("form", form.getName()); - hookSystem.execute( - "forms/%s/submit".formatted(form.getName()), - data); - - if (StringUtil.isNullOrEmpty(form.getTo())) { - return; - } - - var mailService = siteModuleContext.get(InjectorFeature.class).injector().getInstance(MailService.class); - - var message = new Message( - parameters.apply("from"), - new com.condation.cms.api.mail.Message.Recipient("", form.getTo()), - form.getSubject(), - buildMessage(form, parameters) - ); - - mailService.sendText(form.getMail().getAccount(), message); - } catch (Exception e) { - log.error(null, e); - throw new FormHandlingException(e.getMessage()); - } - } -} diff --git a/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java b/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java deleted file mode 100644 index b0e5e1a..0000000 --- a/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.condation.cms.modules.forms.handler; - -/*- - * #%L - * forms-module - * %% - * Copyright (C) 2024 CondationCMS - * %% - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public - * License along with this program. If not, see - * . - * #L% - */ -import com.condation.cms.api.extensions.HttpHandler; -import com.condation.cms.api.hooks.HookSystem; -import com.condation.cms.api.module.SiteModuleContext; -import com.condation.cms.modules.forms.FormsLifecycleExtension; -import com.google.common.base.Strings; -import com.google.gson.Gson; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.http.HttpStatus; -import org.eclipse.jetty.http.MimeTypes; -import org.eclipse.jetty.http.MultiPart; -import org.eclipse.jetty.http.MultiPartFormData; -import org.eclipse.jetty.server.FormFields; -import org.eclipse.jetty.server.Request; -import org.eclipse.jetty.server.Response; -import org.eclipse.jetty.util.Callback; -import org.eclipse.jetty.util.Fields; -import org.eclipse.jetty.util.Promise; - -/** - * - * @author t.marx - */ -@Slf4j -@RequiredArgsConstructor -public class SubmitFormHandler implements HttpHandler { - - private static Gson GSON = new Gson(); - - private final HookSystem hookSystem; - - private final SiteModuleContext siteModuleContext; - - @Override - public boolean handle(Request request, Response response, Callback callback) throws Exception { - - if (!"POST".equalsIgnoreCase(request.getMethod())) { - Response.writeError(request, response, callback, HttpStatus.METHOD_NOT_ALLOWED_405, "invalid request"); - return true; - } - - String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); - - FormsHandling formHandling = new FormsHandling(hookSystem, siteModuleContext); - - try { - if (MimeTypes.Type.FORM_ENCODED.is(contentType)) { - - FormFields.onFields(request, StandardCharsets.UTF_8, new Promise.Invocable() { - @Override - public void failed(Throwable x) { - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - callback.succeeded(); - } - - @Override - public void succeeded(Fields fields) { - try { - - final String formName = fields.get("form").getValue(); - var form = FormsLifecycleExtension.FORMSCONFIG.findForm(formName).get(); - formHandling.handleForm(form, (field) -> { - if (fields.get(field) != null) { - return fields.get(field).getValue(); - } - return field; - }); - response.getHeaders().add("Location", form.getRedirects().getSuccess()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - } catch (FormHandlingException fhe) { - log.error(null, fhe); - var formOpt = fhe.getForm(); - if (formOpt.isPresent() && !Strings.isNullOrEmpty(formOpt.get().getRedirects().getError())) { - response.getHeaders().add("Location", formOpt.get().getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - } else { - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - } - } finally { - callback.succeeded(); - } - } - }); - } else if (contentType.startsWith(MimeTypes.Type.MULTIPART_FORM_DATA.asString())) { - String boundary = MultiPart.extractBoundary(contentType); - MultiPartFormData.Parser parser = new MultiPartFormData.Parser(boundary); - parser.setFilesDirectory(Files.createTempDirectory("cms-upload")); - parser.parse(request, new Promise.Invocable() { - @Override - public void failed(Throwable x) { - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - callback.succeeded(); - } - - @Override - public void succeeded(MultiPartFormData.Parts parts) { - try { - - String formName = parts.getFirst("form").getContentAsString(StandardCharsets.UTF_8); - var form = FormsLifecycleExtension.FORMSCONFIG.findForm(formName).get(); - formHandling.handleForm(form, (field) -> { - if (parts.getAll(field) != null && !parts.getAll(field).isEmpty()) { - return parts.getAll(field).getFirst().getContentAsString(StandardCharsets.UTF_8); - } - return field; - }); - - response.getHeaders().add("Location", form.getRedirects().getSuccess()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - - } catch (FormHandlingException fhe) { - log.error(null, fhe); - var formOpt = fhe.getForm(); - if (formOpt.isPresent() && !Strings.isNullOrEmpty(formOpt.get().getRedirects().getError())) { - response.getHeaders().add("Location", formOpt.get().getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - } else { - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - } - } finally { - callback.succeeded(); - } - } - - }); - } else { - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - callback.succeeded(); - } - } catch (Exception e) { - log.error("error processing form", e); - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - callback.succeeded(); - } - return true; - } -} diff --git a/test-server/.env b/test-server/.env new file mode 100644 index 0000000..80b7bbb --- /dev/null +++ b/test-server/.env @@ -0,0 +1 @@ +CMS_UI_SECRET=xnK82mcK7I9s_K3j-L8vK9L2m_N3o_P4q_R5s_T6u_V7w_X8y_Z9a_B0c_D1e \ No newline at end of file diff --git a/test-server/config/manager-users.realm b/test-server/config/manager-users.realm new file mode 100644 index 0000000..eeee8e3 --- /dev/null +++ b/test-server/config/manager-users.realm @@ -0,0 +1 @@ +test:5Y03iNt821JqVjsutDrZ2DRlKfBefu6mleCg2ThiqdY=:manager:eyJtYWlsIjoidGVzdEBsb2NhbC5kZSIsInNhbHQiOiJneFlPTTZ5V1J1U3NPNGVLRGY3TGh3XHUwMDNkXHUwMDNkIn0= \ No newline at end of file diff --git a/test-server/hosts/demo/assets/thumbnails/mountains.jpg b/test-server/hosts/demo/assets/thumbnails/mountains.jpg new file mode 100644 index 0000000..9256fe1 Binary files /dev/null and b/test-server/hosts/demo/assets/thumbnails/mountains.jpg differ diff --git a/test-server/hosts/demo/config/forms.yaml b/test-server/hosts/demo/config/forms.yaml new file mode 100644 index 0000000..9a5b241 --- /dev/null +++ b/test-server/hosts/demo/config/forms.yaml @@ -0,0 +1,57 @@ +forms: + - name: mail + to: recipient@example.test + subject: Forms E2E mail + fields: + email: + type: email + required: true + message: + required: true + minLength: 5 + maxLength: 500 + mail: + account: default + from: Forms E2E test + captcha: + enabled: false + redirects: + success: /forms/mail-success + error: /forms/mail-error + + - name: validation + fields: + email: + type: email + required: true + message: + required: true + minLength: 10 + captcha: + enabled: false + redirects: + success: /forms/validation-success + error: /forms/validation-error + + - name: security + fields: + message: + required: true + minLength: 5 + captcha: + enabled: false + spam: + honeypot: + enabled: true + field: website + rateLimit: + enabled: true + requests: 2 + periodSeconds: 60 + redirects: + success: /forms/security-success + error: /forms/security-error + +redirects: + success: /forms/success + error: /forms/error diff --git a/test-server/hosts/demo/config/mail.yaml b/test-server/hosts/demo/config/mail.yaml new file mode 100644 index 0000000..03f3db4 --- /dev/null +++ b/test-server/hosts/demo/config/mail.yaml @@ -0,0 +1,7 @@ +accounts: + default: + fromMail: "test@example.test" + host: "127.0.0.1" + port: "3025" + username: "test@example.test" + password: "password" diff --git a/test-server/hosts/demo/config/media.toml b/test-server/hosts/demo/config/media.toml new file mode 100644 index 0000000..599c04b --- /dev/null +++ b/test-server/hosts/demo/config/media.toml @@ -0,0 +1,22 @@ +processor = "imageio" +[[formats]] +name = "small" +width = 256 +height = 256 +format = "webp" +compression = true + +[[formats]] +name = "big" +width = 512 +height = 512 +format = "webp" +compression = true + +[[formats]] +name = "test2" +width = 72 +height = 72 +format = "webp" +compression = true + diff --git a/test-server/hosts/demo/content/.technical/404.md b/test-server/hosts/demo/content/.technical/404.md new file mode 100644 index 0000000..776ccf0 --- /dev/null +++ b/test-server/hosts/demo/content/.technical/404.md @@ -0,0 +1,5 @@ +--- +title: Leider nichts gefunden +template: error.html +--- +Da haben wir leider nichts gefunden! diff --git a/test-server/hosts/demo/content/forms/error.md b/test-server/hosts/demo/content/forms/error.md new file mode 100644 index 0000000..102f62a --- /dev/null +++ b/test-server/hosts/demo/content/forms/error.md @@ -0,0 +1,9 @@ +--- +title: Form failed +result: error +template: result.html +published: true +status: published +--- + +The form could not be processed. diff --git a/test-server/hosts/demo/content/forms/mail-error.md b/test-server/hosts/demo/content/forms/mail-error.md new file mode 100644 index 0000000..e05d288 --- /dev/null +++ b/test-server/hosts/demo/content/forms/mail-error.md @@ -0,0 +1,9 @@ +--- +title: Mail failed +result: mail-error +template: result.html +published: true +status: published +--- + +The mail form could not be processed. diff --git a/test-server/hosts/demo/content/forms/mail-success.md b/test-server/hosts/demo/content/forms/mail-success.md new file mode 100644 index 0000000..2104d92 --- /dev/null +++ b/test-server/hosts/demo/content/forms/mail-success.md @@ -0,0 +1,9 @@ +--- +title: Mail sent +result: mail-success +template: result.html +published: true +status: published +--- + +The mail form was accepted. diff --git a/test-server/hosts/demo/content/forms/mail.md b/test-server/hosts/demo/content/forms/mail.md new file mode 100644 index 0000000..df2a2bf --- /dev/null +++ b/test-server/hosts/demo/content/forms/mail.md @@ -0,0 +1,8 @@ +--- +title: Mail form test +template: mail-form.html +published: true +status: published +--- + +Mail submission test. diff --git a/test-server/hosts/demo/content/forms/security-error.md b/test-server/hosts/demo/content/forms/security-error.md new file mode 100644 index 0000000..39e3205 --- /dev/null +++ b/test-server/hosts/demo/content/forms/security-error.md @@ -0,0 +1,9 @@ +--- +title: Security form rejected +result: security-error +template: result.html +published: true +status: published +--- + +The security form was rejected. diff --git a/test-server/hosts/demo/content/forms/security-success.md b/test-server/hosts/demo/content/forms/security-success.md new file mode 100644 index 0000000..c124520 --- /dev/null +++ b/test-server/hosts/demo/content/forms/security-success.md @@ -0,0 +1,9 @@ +--- +title: Security form successful +result: security-success +template: result.html +published: true +status: published +--- + +The security form was accepted. diff --git a/test-server/hosts/demo/content/forms/security.md b/test-server/hosts/demo/content/forms/security.md new file mode 100644 index 0000000..5f2a7de --- /dev/null +++ b/test-server/hosts/demo/content/forms/security.md @@ -0,0 +1,8 @@ +--- +title: Security form test +template: security-form.html +published: true +status: published +--- + +Security feature test. diff --git a/test-server/hosts/demo/content/forms/success.md b/test-server/hosts/demo/content/forms/success.md new file mode 100644 index 0000000..c1d60bb --- /dev/null +++ b/test-server/hosts/demo/content/forms/success.md @@ -0,0 +1,9 @@ +--- +title: Form successful +result: success +template: result.html +published: true +status: published +--- + +The form was accepted. diff --git a/test-server/hosts/demo/content/forms/validation-error.md b/test-server/hosts/demo/content/forms/validation-error.md new file mode 100644 index 0000000..8bf583c --- /dev/null +++ b/test-server/hosts/demo/content/forms/validation-error.md @@ -0,0 +1,9 @@ +--- +title: Validation failed +result: validation-error +template: result.html +published: true +status: published +--- + +The submitted values were invalid. diff --git a/test-server/hosts/demo/content/forms/validation-success.md b/test-server/hosts/demo/content/forms/validation-success.md new file mode 100644 index 0000000..1396453 --- /dev/null +++ b/test-server/hosts/demo/content/forms/validation-success.md @@ -0,0 +1,9 @@ +--- +title: Validation successful +result: validation-success +template: result.html +published: true +status: published +--- + +The submitted values were valid. diff --git a/test-server/hosts/demo/content/forms/validation.md b/test-server/hosts/demo/content/forms/validation.md new file mode 100644 index 0000000..083800b --- /dev/null +++ b/test-server/hosts/demo/content/forms/validation.md @@ -0,0 +1,8 @@ +--- +title: Validation form test +template: validation-form.html +published: true +status: published +--- + +Validation test. diff --git a/test-server/hosts/demo/content/index.md b/test-server/hosts/demo/content/index.md new file mode 100644 index 0000000..771a1ee --- /dev/null +++ b/test-server/hosts/demo/content/index.md @@ -0,0 +1,7 @@ +--- +title: forms test site +template: start.html +published: true +status: published +--- + diff --git a/test-server/hosts/demo/site.toml b/test-server/hosts/demo/site.toml new file mode 100644 index 0000000..b4280fe --- /dev/null +++ b/test-server/hosts/demo/site.toml @@ -0,0 +1,9 @@ +id = "demo-site" +hostname = [ "localhost", "127.0.0.1" ] +baseurl = "http://localhost:2020" +locale = "en_US" +context_path = "/" + +# modules to load for this site +[modules] +active = ["forms-module"] # list of active modules for this sites \ No newline at end of file diff --git a/test-server/hosts/demo/templates/mail-form.html b/test-server/hosts/demo/templates/mail-form.html new file mode 100644 index 0000000..d9d3ecd --- /dev/null +++ b/test-server/hosts/demo/templates/mail-form.html @@ -0,0 +1,23 @@ + + + + + {{ node.meta.title }} + + +
+

Mail form

+ + + + + + + + + + + +
+ + diff --git a/test-server/hosts/demo/templates/result.html b/test-server/hosts/demo/templates/result.html new file mode 100644 index 0000000..58e7006 --- /dev/null +++ b/test-server/hosts/demo/templates/result.html @@ -0,0 +1,13 @@ + + + + + {{ node.meta.title }} + + +
+

{{ node.meta.result }}

+ {{ node.content | raw }} +
+ + diff --git a/test-server/hosts/demo/templates/security-form.html b/test-server/hosts/demo/templates/security-form.html new file mode 100644 index 0000000..76f4c92 --- /dev/null +++ b/test-server/hosts/demo/templates/security-form.html @@ -0,0 +1,25 @@ + + + + + {{ node.meta.title }} + + +
+

Security form

+
+ + + + + + + + +
+
+ + diff --git a/test-server/hosts/demo/templates/start.html b/test-server/hosts/demo/templates/start.html new file mode 100644 index 0000000..006443b --- /dev/null +++ b/test-server/hosts/demo/templates/start.html @@ -0,0 +1,17 @@ + + + + + + {{ node.meta.title }} + + + + + + {{ node.content | raw }} + + + + + \ No newline at end of file diff --git a/test-server/hosts/demo/templates/validation-form.html b/test-server/hosts/demo/templates/validation-form.html new file mode 100644 index 0000000..4ab8a7a --- /dev/null +++ b/test-server/hosts/demo/templates/validation-form.html @@ -0,0 +1,23 @@ + + + + + {{ node.meta.title }} + + +
+

Validation form

+
+ + + + + + + + + +
+
+ + diff --git a/test-server/log4j2.xml b/test-server/log4j2.xml new file mode 100644 index 0000000..3c88dbf --- /dev/null +++ b/test-server/log4j2.xml @@ -0,0 +1,69 @@ + + + + %m%n + %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1} - %m%n + ${sys:cms-logs-folder} + 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-server/server.toml b/test-server/server.toml new file mode 100644 index 0000000..5daacce --- /dev/null +++ b/test-server/server.toml @@ -0,0 +1,34 @@ +# environment: dev and prod +env = "dev" + +# server settings +[server] +port = 2020 # server port +ip = "127.0.0.1" # ip bind to + +# inter process communication +[ipc] +port = 6868 # ipc port +password = "test_pwd" # ipc password + +# application performance management +[apm] +enabled = false # enable +max_requests = 100 # max requests per remote IP +thread_limit = 10 # thread limit per remote IP + +[performance] +pool_enabled = false +pool_size = 10 +pool_expire = 1000 + + +[list] +test = ["eins", "zwei"] + +[map] +test = {"key1"="value2", "key2"="value2"} + +# ui manager properties +[ui] +secret = "${env:CMS_UI_SECRET}" # secret for JWT