diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/HtmlProduct.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/HtmlProduct.java new file mode 100644 index 00000000000..0efd463d581 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/HtmlProduct.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend; + +public final class HtmlProduct { + private final int id; + private final String name; + + public HtmlProduct(int id, String name) { + this.id = id; + this.name = name; + } + + public int getId() { return id; } + public String getName() { return name; } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/HtmlProducts.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/HtmlProducts.java new file mode 100644 index 00000000000..b9dc5543598 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/HtmlProducts.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend; + +import com.codename1.backend.annotations.Controller; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.ModelAttribute; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.mvc.BindingResult; +import com.codename1.backend.mvc.Model; +import java.util.ArrayList; +import java.util.List; + +@Controller +public class HtmlProducts { + private final List products = new ArrayList(); + + // tag::backend-views-controller[] + @GetMapping("/products") + public synchronized String list(Model model) { + model.addAttribute("products", new ArrayList(products)); + return "products"; + } + // end::backend-views-controller[] + + // tag::backend-views-save[] + @PostMapping("/products") + public synchronized String save(@ModelAttribute("form") ProductForm form, + BindingResult errors, Model model) { + if (form.name == null || form.name.trim().isEmpty()) { + errors.rejectValue("name", "Enter a name."); + } + if (errors.hasErrors()) { + return "edit"; + } + products.add(new HtmlProduct(products.size() + 1, form.name)); + return "redirect:/products"; + } + // end::backend-views-save[] + +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/ProductForm.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/ProductForm.java new file mode 100644 index 00000000000..e8be675a6b5 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/ProductForm.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend; + +/// Dedicated editable fields for the compiled-view guide example. +public class ProductForm { + public String name; +} diff --git a/docs/demos/backend/src/main/snippets/edit.html b/docs/demos/backend/src/main/snippets/edit.html new file mode 100644 index 00000000000..2da9097285b --- /dev/null +++ b/docs/demos/backend/src/main/snippets/edit.html @@ -0,0 +1,9 @@ + + +
+ + + + +
+ diff --git a/docs/demos/backend/src/main/snippets/products.html b/docs/demos/backend/src/main/snippets/products.html new file mode 100644 index 00000000000..34cb32a91ce --- /dev/null +++ b/docs/demos/backend/src/main/snippets/products.html @@ -0,0 +1,9 @@ + + + + diff --git a/docs/developer-guide/Backend-Views.asciidoc b/docs/developer-guide/Backend-Views.asciidoc new file mode 100644 index 00000000000..6c5290fe7a0 --- /dev/null +++ b/docs/developer-guide/Backend-Views.asciidoc @@ -0,0 +1,186 @@ +[[backend-views]] +== Compiled HTML views + +The backend can serve HTML applications without a Codename One client. Write HTML +with a supported subset of Thymeleaf attributes, return view names from Java +controllers, and use htmx when an interaction should update part of the page. +The build parses the templates and produces Java renderers. Native packaging +translates those renderers through ParparVM to C along with the rest of the server. +There is no template engine, expression interpreter or compiler in the request path. + +The runnable example is `scripts/backend-mvc`. It includes an in-memory product +catalog, form errors, CSRF protection, shared page fragments, and a pinned local +htmx script. Its forms work with JavaScript disabled too. + +=== Controllers and models + +Use `com.codename1.backend.annotations.Controller` for HTML controllers and +`com.codename1.backend.mvc.Model` for their model. Existing REST controllers retain +their response semantics. An HTML controller uses the same mapping annotations, +constructor injection, sessions and security chains as a REST controller. + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/HtmlProducts.java[tag=backend-views-controller,indent=0] +---- + +A string return value selects a compiled view. `products` selects +`src/main/resources/templates/products.html`; `products :: rows` selects that +file's named fragment. `redirect:/products` redirects to a local absolute path: +303 for normal requests, or 200 with `HX-Redirect` for requests from htmx. A null +return is 404. Unknown view names fail the request; they never resolve file paths. + +`ModelAndView` carries a view name and attributes added with `addObject`. +`@ResponseBody`, on the method or controller, retains the REST response rules. +An explicit `HttpServer.Response` is returned unchanged. A view's default status +is 200; `@ResponseStatus` can change it. + +=== Typed HTML + +Declare model types with comments. Names are explicit and types are fully qualified: + +[source,html] +---- +include::../demos/backend/src/main/snippets/products.html[tag=backend-views-list,indent=0] +---- + +The build resolves public JavaBean getters or public fields and emits direct +access. Generic type arguments are preserved through getters, fields, and inherited +classes or interfaces. A missing property or undeclared model name fails the build with the +file and source position. At runtime, a referenced model must be present and have +the declared type. A present null renders as empty text; property navigation +propagates null. Numeric comparisons require non-null operands. Fragments only +require model names they read. Loops accept collections and object or primitive +arrays, and check element types before access. Null collections and arrays produce +no rows. + +Supported directives: + +* `th:text`, `th:if`, `th:unless`, and `th:each="item, status : ${items}"`. + The optional status exposes `index`, `count`, `size`, `first`, `last`, `even`, and `odd`. +* `th:href`, `src`, `action`, `value`, `id`, `name`, `class`, `title`, `alt`, + `placeholder`, `method`, and `for`, plus `th:classappend`. +* Boolean attributes `checked`, `selected`, `disabled`, `readonly`, `multiple`, + `required`, `autofocus`, and `hidden`. +* `th:attr="attribute=expression, other=expression"`, including htmx URLs. +* `th:fragment="name"`, `th:insert="~{layout :: name}"`, and + `th:replace="~{layout :: name}"`. References are static, fragments have no + parameters, and recursive inclusion is a build error. `th:block` adds no wrapper. +* `th:object`, `th:field`, and `th:errors` for forms. + +Expressions support `${model.property}`, typed list/array/map indexing, string, +number, boolean and null literals, boolean operators, equality, numeric comparisons, +addition/subtraction, and `condition ? yes : no`. Selection expressions `*{field}` +use the enclosing `th:object`. URL expressions use local paths with encoded path +and query arguments, such as `@{/search(q=${query})}`. + +Dynamic text and attributes are HTML-escaped. URL arguments use URL encoding +before attribute escaping. Dynamic URL attributes reject executable URL schemes. +Dynamic htmx attributes that evaluate expressions (`hx-on*`, `hx-vars`, `hx-vals`, +`hx-headers`, `hx-request`, and `hx-trigger`) are rejected, including `data-hx-*` +aliases. Define those attributes as static template content. +Script sources, link-resource URLs (including stylesheets), and base URLs must be +static template attributes. Meta `http-equiv` directives, including refresh content, +must also be static. Named metadata such as a description can use dynamic content. +Dynamic attributes are evaluated once per rendered +element, including each iteration of a loop. +Raw HTML, dynamic script/style content, arbitrary method calls, expression +preprocessing, message expressions, inline expressions, custom dialects, and +Spring EL are outside this subset. Unsupported `th:` attributes fail the build. +This is a syntax-compatible subset, not the Thymeleaf library. + +=== Forms and validation + +A mapped method can receive a dedicated form DTO: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/HtmlProducts.java[tag=backend-views-save,indent=0] +---- + +Form DTOs need a public no-argument constructor and writable scalar fields or +setters. Supported values are strings, numeric primitives/boxes, booleans and +characters. Nested objects, collections, file uploads through the form DTO, and +ORM entities are refused. Bind identifiers through route parameters. +Unknown submitted fields are ignored; no property path from a request is executed. + +`BindingResult` must immediately follow its form parameter. Conversion errors are +recorded before the controller runs. Without a `BindingResult`, conversion failure +returns 400. `reject(message)` adds a global error; `rejectValue(field, message)` +adds a field error. Validation is application code; Bean Validation annotations +aren't implemented. + +[source,html] +---- +include::../demos/backend/src/main/snippets/edit.html[tag=backend-views-form,indent=0] +---- + +`th:field` renders the name, a default id, and the submitted value if binding failed. +It supports inputs, textareas, scalar selects, checkboxes and radio buttons. +Boolean checkboxes use field presence, including with custom values. They emit a +hidden marker so an unchecked field binds to false. The marker shares the +checkbox's disabled state and form association. Without a marker, Boolean fields +use strict value conversion. +`th:errors="*{*}"` renders all errors for the selected form. Use distinct explicit +ids for radio buttons in a group. Multiple-selection collection binding is deferred. + +Each `@ModelAttribute` parameter on a route must have a distinct name; duplicate +names fail the build instead of overwriting form objects and validation results. + +=== htmx, CSRF and assets + +Ordinary `hx-*` attributes pass through. Put `hx-post` on a form and target a +named fragment's wrapper. `Htmx.isRequest(request)` distinguishes fragment requests +from normal navigation and history restoration. A controller chooses the view; +request headers never select a template themselves. Return form-error fragments +with status 200 so htmx swaps them normally. `Htmx.redirect`, `refresh`, and `trigger` +provide response-header helpers. + +The existing security chain remains responsible for CSRF enforcement. The model +contains `_csrf`, whose type is declared automatically. Unsafe forms include a +hidden CSRF token when one is available for native POST submissions. Mutating +htmx controls also send the configured CSRF header, including standalone controls +outside forms. Authored `hx-headers` must be a JSON object on these controls; +other headers are preserved, and the configured token header takes precedence. +Form `action`, submit-control `formaction`, htmx URLs (`hx-get`, `hx-post`, +`hx-put`, `hx-patch`, `hx-delete`), and static base URLs must be local +absolute paths such as `/products/save`, or empty to use the current document. +This applies to static and dynamic form destinations, including controls in fragments, +to prevent submissions from disclosing the generated token to another origin. +This includes read requests because htmx inherits headers from ancestor elements. +For `hx-get` and `hx-delete`, generated `hx-params` filtering excludes the CSRF parameter while +preserving the token for a native POST fallback. Explicit parameter allow lists +and exclusion lists are retained; included fragments receive their parent's +original parameter filter, so nested POST requests don't inherit the URL parameter +CSRF exclusion. The configured CSRF parameter name is used. +When a CSRF token is available, submit controls can't override the native method +with GET or an invalid value that defaults to GET. This restriction also applies +to controls in fragments or controls linked to a form by its ID. Use a separate +GET form for searches and previews. POST and dialog overrides remain supported. +Form `enctype` and submit-control `formenctype` support +`application/x-www-form-urlencoded` and `multipart/form-data`. Unsupported static +encodings fail the build; unsupported dynamic values fail rendering. In particular, +`text/plain` isn't supported by form binding. +For requests outside forms, supply the existing token header explicitly. Defining +HTML controllers doesn't enable a security chain automatically. + +Files in `src/main/resources/static` are embedded at build time and served under +`/static/` after controller routes. Only enumerated assets are accessible; templates +are private. Embedded assets use `Cache-Control: no-cache` and `nosniff`. Use URL-safe +ASCII filenames. Each embedded asset is limited to 2 MiB; use the existing +`cn1.static.root` facility for larger files. + +=== Building and porting + +Maven compiles views during `process-annotations` in `process-classes`; native +packaging also runs this compiler. Gradle uses the same engine and tracks templates +and static assets as compilation inputs. Rebuild after editing HTML. Generated +view and asset registries are replaced on each build, including after file deletion. +The first request only executes generated code. + +To port a small Spring MVC application, change the controller/model imports, add +template model declarations, use dedicated scalar form DTOs, and replace unsupported +expressions or dialect features. Keep existing HTML and htmx attributes within the +supported subset. Controller advice, method-level `@ModelAttribute`, flash attributes, +`forward:` views, custom converters, dynamic fragment selectors, and internationalized +message bundles aren't part of this first version. diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index 1d032f8d4b1..3dcb1b63948 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -58,6 +58,7 @@ a server and go into depth: * <>: mapping HTTP requests to methods, sharing a typed contract with the app, and holding WebSocket connections open. +* <>: compiled HTML pages, typed templates, forms and htmx fragments. * <>: splitting a server into services the build wires together, with scopes, conditions and configuration binding. * <>: the connection pool, the object mapping, and diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index f257d97f93c..ebad464ff73 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -123,6 +123,8 @@ include::Backend.asciidoc[] include::Backend-Web.asciidoc[] +include::Backend-Views.asciidoc[] + include::Backend-Beans.asciidoc[] include::Backend-Testing.asciidoc[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index b5e6d3e05a4..c71b484ddbf 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -1,4 +1,8 @@ # LanguageTool accept list for the developer guide. +# Compiled HTML views: library names and the IEC mebibyte unit symbol. +htmx +Thymeleaf +MiB # # Each non-blank, non-comment line is a Python regex matched against the # exact text LanguageTool flagged (m.context[offsetInContext: diff --git a/maven/backend/src/test/java/com/codename1/backend/security/SecurityFilterChainTest.java b/maven/backend/src/test/java/com/codename1/backend/security/SecurityFilterChainTest.java index a20db315069..ab95f4d8494 100644 --- a/maven/backend/src/test/java/com/codename1/backend/security/SecurityFilterChainTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/security/SecurityFilterChainTest.java @@ -27,13 +27,17 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.codename1.backend.Base64; +import com.codename1.backend.ByteSink; import com.codename1.backend.HttpServer; import com.codename1.backend.WebSocket; import com.codename1.backend.WebSocketSession; +import com.codename1.backend.mvc.Html; +import com.codename1.backend.mvc.Model; import com.codename1.backend.security.SecuredServer.Reply; import com.codename1.backend.security.core.userdetails.InMemoryUserDetailsManager; import com.codename1.backend.security.core.userdetails.User; @@ -799,6 +803,49 @@ void configuredSecurityHeaders() throws Exception { // -------------------------------------------------------------------- CSRF + @Test + void mvcModelsDeferCsrfUntilTheTokenIsUsed() throws Exception { + HttpServer.Handler pages = request -> { + Model source = Html.model(request); + Model model = new Model().addAllAttributes(source); + String path = request.pathFrom(0); + assertTrue(model.containsAttribute("_csrf")); + if (path.endsWith("/read")) { + return HttpServer.Response.text(200, "read-only"); + } + if (path.endsWith("/override")) { + model.addAttribute("_csrf", null); + } + ByteSink out = new ByteSink(128); + Html.csrf(out, model); + CsrfToken token = (CsrfToken) model.getAttribute("_csrf"); + assertSame(token, model.getAttribute("_csrf")); + String text = new String(Html.bytes(out), "UTF-8"); + if (token != null) { + assertSame(token, source.getAttribute("_csrf")); + assertTrue(text.contains("name=\"" + token.getParameterName() + "\"")); + assertTrue(text.contains("value=\"" + token.getToken() + "\"")); + } + return HttpServer.Response.text(200, token == null ? "none" : token.getToken()); + }; + try (SecuredServer server = SecuredServer.start(dev(), "test", beans(), pages, + http -> http.securityMatcher("/open/**").csrf(csrf -> csrf.disable()).build(), + http -> http.build())) { + for (String path : new String[] {"/read", "/open/form", "/override"}) { + Reply reply = server.get(path); + assertEquals(200, reply.status); + assertTrue(reply.headers("Set-Cookie").isEmpty(), reply.toString()); + assertTrue(server.cookies.isEmpty()); + } + Reply form = server.get("/form"); + assertEquals(200, form.status); + assertNotNull(server.cookies.get("CN1SESSION")); + assertNotEquals("none", form.body); + assertEquals(403, server.post("/save", "").status); + assertEquals(200, server.post("/save", "_csrf=" + form.body).status); + } + } + @Test void csrfCookieCannotBeInjectedOrCopiedFromAnotherSession() throws Exception { try (SecuredServer server = SecuredServer.start(dev(), "test", beans(), APP, diff --git a/maven/build-engine/pom.xml b/maven/build-engine/pom.xml index 30c21377621..8369fa0d079 100644 --- a/maven/build-engine/pom.xml +++ b/maven/build-engine/pom.xml @@ -31,6 +31,8 @@ + + org.jsoupjsoup1.17.2 ") + .matcher(text); + t.models.put("_csrf", "com.codename1.backend.security.CsrfToken"); + while (declarations.find()) { + String name = declarations.group(1), type = declarations.group(2).trim(); + type = checkType(type); + if (t.models.put(name, type) != null) + throw new IllegalArgumentException("Duplicate/reserved model name " + name); + } + validate(t, t.document); + } catch (IOException error) { + throw new IllegalArgumentException("Cannot read template " + file, error); + } catch (IllegalArgumentException error) { + throw new IllegalArgumentException(file + ": " + error.getMessage(), error); + } + templates.put(t.name, t); + } + } + + private String checkType(String type) { + if (type.matches("(boolean|byte|short|int|long|char|float|double)(\\[\\])+")) return type; + // Only named reference types and generic arguments: declarations are not Java snippets. + if (!type.matches("[A-Za-z_$][A-Za-z0-9_$.]*(\\s*<.*>)?(\\[\\])*") + || type.matches(".*[^A-Za-z0-9_$.,<>\\[\\] ].*")) + throw new IllegalArgumentException("Invalid model type " + type); + int depth = 0; + for (char c : type.toCharArray()) { + if (c == '<') depth++; + if (c == '>') depth--; + if (depth < 0) throw new IllegalArgumentException("Invalid model type " + type); + } + if (depth != 0) throw new IllegalArgumentException("Invalid model type " + type); + for (String word : type.split("[^A-Za-z0-9_$.]+")) { + if (word.isEmpty()) continue; + if (!Arrays.asList( + "java.lang.String", + "java.lang.Boolean", + "java.lang.Byte", + "java.lang.Short", + "java.lang.Integer", + "java.lang.Long", + "java.lang.Float", + "java.lang.Double", + "java.lang.Character", + "java.util.List", + "java.util.Set", + "java.util.Collection", + "java.util.Map") + .contains(word) + && MvcTypes.resolveClass(ctx, word) == null) + throw new IllegalArgumentException("Unknown model type " + word); + } + return MvcTypes.sourceType(ctx, type); + } + + private void validate(Template t, Node node) { + if (node instanceof Element) { + Element e = (Element) node; + // htmx recognizes both spellings. Normalize before overrides and CSRF analysis. + for (Attribute attribute : new ArrayList(e.attributes().asList())) { + String canonical = canonicalAttribute(attribute.getKey()); + if (!canonical.equals(attribute.getKey())) { + if (!e.hasAttr(canonical)) e.attr(canonical, attribute.getValue()); + e.removeAttr(attribute.getKey()); + } + } + if ((e.normalName().equals("script") || e.normalName().equals("style")) + && (e.hasAttr("th:text") || e.hasAttr("th:insert") || e.hasAttr("th:errors"))) + throw problem(t, e, "Dynamic script/style content is not supported"); + for (Attribute a : e.attributes()) + if (a.getKey().startsWith("th:")) { + String n = a.getKey().substring(3); + if (!DIRECTIVES.contains(n) && !ATTRS.contains(n) && !BOOLEAN.contains(n)) + throw problem(t, e, "Unsupported directive " + a.getKey()); + } + if (e.hasAttr("th:fragment")) { + String name = e.attr("th:fragment"); + if (!name.matches("[A-Za-z][A-Za-z0-9_-]*")) + throw problem( + t, + e, + "Fragment names must be simple identifiers (parameters are not" + + " supported)"); + if (t.fragments.put(name, e) != null) + throw problem(t, e, "Duplicate fragment " + name); + } + } + for (Node child : node.childNodes()) validate(t, child); + } + + private String methodName(String key) { + return "view" + new ArrayList(methods.keySet()).indexOf(key); + } + + private void compile(String key) { + if (compiling.contains(key)) + throw new IllegalArgumentException("Recursive fragment inclusion: " + key); + if (methods.containsKey(key)) return; + int sep = key.indexOf(" :: "); + String name = sep < 0 ? key : key.substring(0, sep); + Template t = templates.get(name); + if (t == null) throw new IllegalArgumentException("Unknown template " + name); + Node root = sep < 0 ? t.document : t.fragments.get(key.substring(sep + 4)); + if (root == null) throw new IllegalArgumentException("Unknown fragment " + key); + methods.put(key, ""); + compiling.add(key); + Map env = new LinkedHashMap(); + StringBuilder prelude = new StringBuilder(), body = new StringBuilder(); + for (Map.Entry entry : t.models.entrySet()) { + String variable = "model" + sequence++; + env.put(entry.getKey(), new MvcExpression.Value(variable, entry.getValue())); + } + env.put("@hxParams", new MvcExpression.Value("inheritedParams", "java.lang.String")); + render(t, root, env, null, null, body); + // A fragment only requires the model names it actually reads. + for (Map.Entry entry : t.models.entrySet()) { + String variable = env.get(entry.getKey()).code; + if (!env.get(entry.getKey()).read) continue; + String type = entry.getValue(), raw = MvcExpression.raw(type); + prelude.append("Object raw_") + .append(variable) + .append(" = Html.require(model, ") + .append(q(entry.getKey())) + .append(", ") + .append(q(key)) + .append(");\n") + .append("if (raw_") + .append(variable) + .append(" != null && !(raw_") + .append(variable) + .append(" instanceof ") + .append(raw) + .append(")) throw new IllegalStateException(") + .append( + q( + "Wrong model type for " + + entry.getKey() + + " in " + + key + + ": expected " + + type)) + .append(");\n") + .append(type) + .append(' ') + .append(variable) + .append(" = (") + .append(type) + .append(")raw_") + .append(variable) + .append(";\n"); + } + methods.put( + key, + "private static void " + + methodName(key) + + "(ByteSink out, Model model, String inheritedParams) {\n" + + prelude + + body + + "}\n"); + compiling.remove(key); + } + + private void render( + Template t, + Node node, + Map env, + String form, + String select, + StringBuilder out) { + try { + renderNode(t, node, env, form, select, out); + } catch (IllegalArgumentException error) { + throw problem(t, node, error.getMessage()); + } + } + + private void renderNode( + Template t, + Node node, + Map original, + String form, + String select, + StringBuilder out) { + if (node instanceof Comment) { + if (!((Comment) node).getData().trim().startsWith("cn1:model")) + literal(out, node.outerHtml()); + return; + } + if (!(node instanceof Element)) { + literal(out, node.outerHtml()); + return; + } + Element e = (Element) node; + Map env = + new LinkedHashMap(original); + int braces = 0; + if (e.hasAttr("th:each")) { + String each = e.attr("th:each"); + int colon = each.indexOf(':'); + if (colon < 0) throw new IllegalArgumentException("Expected th:each='item : ${items}'"); + String[] names = each.substring(0, colon).trim().split("\\s*,\\s*"); + if (names.length > 2) throw new IllegalArgumentException("Invalid iteration variables"); + for (String name : names) + if (!name.matches("[A-Za-z][A-Za-z0-9_]*")) + throw new IllegalArgumentException("Invalid iteration variable"); + MvcExpression.Value list = expression(each.substring(colon + 1), env, form); + String itemType = MvcExpression.box(MvcExpression.element(list.type)), + listVar = "list" + sequence++, + itemVar = "item" + sequence++; + out.append(list.type) + .append(' ') + .append(listVar) + .append(" = ") + .append(list.code) + .append(";\nif(") + .append(listVar) + .append(" != null) {\n"); + braces++; + String index = "index" + sequence++; + out.append("int ").append(index).append(" = 0;\n"); + String rawItem = "rawItem" + sequence++; + out.append("for(Object ").append(rawItem).append(" : ").append(listVar).append(") {\n"); + braces++; + out.append("if(") + .append(rawItem) + .append(" != null && !(") + .append(rawItem) + .append(" instanceof ") + .append(MvcExpression.raw(itemType)) + .append( + ")) throw new IllegalStateException(\"Wrong collection element" + + " type\");\n") + .append(itemType) + .append(' ') + .append(itemVar) + .append(" = (") + .append(itemType) + .append(")") + .append(rawItem) + .append(";\n"); + env.put(names[0], new MvcExpression.Value(itemVar, itemType)); + String status = "status" + sequence++; + out.append("com.codename1.backend.mvc.IterationStatus ") + .append(status) + .append(" = new com.codename1.backend.mvc.IterationStatus(") + .append(index) + .append("++, ") + .append(listVar) + .append(list.type.endsWith("[]") ? ".length" : ".size()") + .append(");\n"); + env.put( + names.length == 2 ? names[1] : names[0] + "Stat", + new MvcExpression.Value(status, "com.codename1.backend.mvc.IterationStatus")); + } + if (e.hasAttr("th:object")) { + form = e.attr("th:object").trim(); + if (!form.matches("\\$\\{[A-Za-z][A-Za-z0-9_]*}")) + throw new IllegalArgumentException("th:object requires a named form model"); + form = form.substring(2, form.length() - 1); + if (!env.containsKey(form)) + throw new IllegalArgumentException("Undeclared form " + form); + } + for (String condition : Arrays.asList("if", "unless")) + if (e.hasAttr("th:" + condition)) { + out.append("if(") + .append(condition.equals("unless") ? "!" : "") + .append( + MvcExpression.truth( + expression(e.attr("th:" + condition), env, form))) + .append(") {\n"); + braces++; + } + if (e.hasAttr("th:replace")) { + include(t, e.attr("th:replace"), env, out); + close(out, braces); + return; + } + String tag = e.normalName(); + boolean block = tag.equals("#root") || tag.equals("th:block"); + Map dynamic = new LinkedHashMap(); + for (Attribute a : e.attributes()) { + String key = a.getKey(); + if (!key.startsWith("th:")) continue; + String n = key.substring(3); + if (ATTRS.contains(n) || BOOLEAN.contains(n)) + dynamic.put(n, expression(a.getValue(), env, form)); + if (n.equals("attr")) + for (String assignment : split(a.getValue(), ',')) { + int equal = assignment.indexOf('='); + if (equal < 1) + throw new IllegalArgumentException("Expected attribute=expression"); + String attr = + assignment + .substring(0, equal) + .trim() + .toLowerCase(java.util.Locale.ROOT); + attr = canonicalAttribute(attr); + if (!attr.matches("[a-z][a-z0-9:_-]*") + || attr.startsWith("on") + || attr.equals("style") + || attr.equals("srcdoc") + || attr.startsWith("th:") + || attr.startsWith("hx-on") + || attr.equals("hx-vars") + || attr.equals("hx-request") + || attr.equals("hx-trigger") + || attr.equals("hx-vals") + || attr.equals("hx-headers")) + throw new IllegalArgumentException("Unsupported dynamic attribute " + attr); + dynamic.put(attr, expression(assignment.substring(equal + 1), env, form)); + } + } + Set snapshots = new HashSet(); + cacheAttributes(dynamic, snapshots, out); + if (tag.equals("meta") + && (dynamic.containsKey("http-equiv") + || (e.hasAttr("http-equiv") && dynamic.containsKey("content")))) + throw new IllegalArgumentException( + "Dynamic meta http-equiv directives are not supported"); + for (String attribute : Arrays.asList("enctype", "formenctype", "formmethod")) { + if (attribute.equals("enctype") + ? !tag.equals("form") + : !(tag.equals("button") || tag.equals("input"))) continue; + MvcExpression.Value value = dynamic.get(attribute); + if (value == null && !e.hasAttr(attribute)) continue; + boolean method = attribute.equals("formmethod"); + if (!method && value == null) { + String encoding = e.attr(attribute); + if (!encoding.isEmpty() + && !encoding.equalsIgnoreCase("application/x-www-form-urlencoded") + && !encoding.equalsIgnoreCase("multipart/form-data")) + throw new IllegalArgumentException("Unsupported form encoding: " + encoding); + } + String code = value == null ? q(e.attr(attribute)) : value.code; + String checked = + HTML + (method ? "submitMethod(model, " : "formEncoding(") + code + ")"; + if (value == null) out.append(checked).append(";\n"); + else dynamic.put(attribute, new MvcExpression.Value(checked, "java.lang.String")); + } + if (tag.equals("link") && dynamic.containsKey("href")) + throw new IllegalArgumentException("Dynamic link resource URLs are not supported"); + if (tag.equals("script") + && (dynamic.containsKey("src") + || dynamic.containsKey("href") + || dynamic.containsKey("xlink:href"))) + throw new IllegalArgumentException("Dynamic script sources are not supported"); + if (tag.equals("base") && dynamic.containsKey("href")) + throw new IllegalArgumentException("Dynamic base URLs are not supported"); + // Every submission destination must stay on this origin, including submit controls + // rendered in separate fragments. A base URL must not redirect local paths off-origin. + for (String attribute : + Arrays.asList( + "action", + "formaction", + "href", + "hx-get", + "hx-post", + "hx-put", + "hx-patch", + "hx-delete")) { + if (attribute.equals("href") && !tag.equals("base")) continue; + MvcExpression.Value value = dynamic.get(attribute); + if (value == null && !e.hasAttr(attribute)) continue; + String code = value == null ? q(e.attr(attribute)) : value.code; + String checked = + helpers.call( + "java.lang.String", + "java.lang.Object value", + "if (value == null) return null; String url = Html.string(value); if" + + " (!url.isEmpty()) { try { Html.localLocation(url); } catch" + + " (IllegalArgumentException error) { throw new" + + " IllegalArgumentException(\"Form action, htmx and base" + + " URLs must be a local absolute path\"); } } return url;", + code); + if (value == null) out.append(checked).append(";\n"); + else dynamic.put(attribute, new MvcExpression.Value(checked, "java.lang.String")); + } + if (e.hasAttr("th:classappend")) { + MvcExpression.Value v = expression(e.attr("th:classappend"), env, form); + MvcExpression.Value base = dynamic.get("class"); + dynamic.put( + "class", + new MvcExpression.Value( + (base == null ? q(e.attr("class")) : HTML + "string(" + base.code + ")") + + " + \" \" + " + + HTML + + "string(" + + v.code + + ")", + "java.lang.String")); + } + String field = null, fieldValue = null; + if (e.hasAttr("th:field")) { + field = field(e.attr("th:field"), form); + MvcExpression.Value value = expression(e.attr("th:field"), env, form); + fieldValue = + HTML + "field(model, " + q(form) + ", " + q(field) + ", " + value.code + ")"; + String fieldLocal = "field" + sequence++; + out.append("Object ").append(fieldLocal).append(" = ").append(fieldValue).append(";\n"); + fieldValue = fieldLocal; + dynamic.put("name", new MvcExpression.Value(q(field), "java.lang.String")); + if (!e.hasAttr("id") && !dynamic.containsKey("id")) + dynamic.put("id", new MvcExpression.Value(q(field), "java.lang.String")); + if (tag.equals("select")) select = fieldValue; + else if (tag.equals("input")) { + MvcExpression.Value inputType = dynamic.get("type"); + String checkbox = + inputType == null + ? Boolean.toString(e.attr("type").equalsIgnoreCase("checkbox")) + : "\"checkbox\".equalsIgnoreCase(Html.string(" + + inputType.code + + "))"; + String choice = + inputType == null + ? "true" + : "(" + + checkbox + + " || \"radio\".equalsIgnoreCase(Html.string(" + + inputType.code + + ")))"; + if (inputType != null + || e.attr("type").equalsIgnoreCase("checkbox") + || e.attr("type").equalsIgnoreCase("radio")) { + MvcExpression.Value candidate = dynamic.get("value"); + if (candidate == null) + candidate = + new MvcExpression.Value( + q(e.hasAttr("value") ? e.attr("value") : "true"), + "java.lang.String"); + dynamic.put( + "value", + inputType == null + ? candidate + : new MvcExpression.Value( + "(" + + choice + + " ? (Object)(" + + candidate.code + + ") : " + + fieldValue + + ")", + "java.lang.Object")); + String checked = HTML + "checked(" + fieldValue + ", " + candidate.code + ")"; + if ("java.lang.Boolean".equals(value.type) || "boolean".equals(value.type)) + checked = + "(" + + checkbox + + " ? " + + HTML + + "truth(" + + fieldValue + + ") : " + + checked + + ")"; + dynamic.put( + "checked", + new MvcExpression.Value(choice + " && " + checked, "boolean")); + if (inputType != null) out.append("if (").append(checkbox).append(") {\n"); + if (inputType != null || e.attr("type").equalsIgnoreCase("checkbox")) { + literal(out, ""); + } + if (inputType != null) out.append("}\n"); + } else + dynamic.put("value", new MvcExpression.Value(fieldValue, "java.lang.Object")); + } else if (!tag.equals("textarea")) + throw new IllegalArgumentException("th:field requires input, select or textarea"); + } + String optionText = null; + if (tag.equals("option") && select != null) { + MvcExpression.Value candidate = dynamic.get("value"); + if (candidate == null) { + if (e.hasAttr("value")) + candidate = new MvcExpression.Value(q(e.attr("value")), "java.lang.String"); + else { + MvcExpression.Value text = + e.hasAttr("th:text") + ? expression(e.attr("th:text"), env, form) + : new MvcExpression.Value(q(e.wholeText()), "java.lang.String"); + optionText = "optionText" + sequence++; + out.append("String ") + .append(optionText) + .append(" = ") + .append(HTML) + .append("string(") + .append(text.code) + .append(");\n"); + candidate = + new MvcExpression.Value( + HTML + "optionValue(" + optionText + ")", "java.lang.String"); + } + } + dynamic.put( + "selected", + new MvcExpression.Value( + HTML + "checked(" + select + ", " + candidate.code + ")", "boolean")); + } + // Keep the author's inherited filter separate from the URL parameter security exclusion. + // Included fragments receive that original filter too, so POST descendants retain it. + String paramsLocal = env.get("@hxParams").code; + MvcExpression.Value ownParams = dynamic.get("hx-params"); + if (ownParams != null || e.hasAttr("hx-params")) { + String own = ownParams != null ? ownParams.code : q(e.attr("hx-params")); + String inherited = paramsLocal; + paramsLocal = "params" + sequence++; + out.append("String ") + .append(paramsLocal) + .append(" = ") + .append(own) + .append(" == null ? ") + .append(inherited) + .append(" : ") + .append(own) + .append(";\n"); + env.put("@hxParams", new MvcExpression.Value(paramsLocal, "java.lang.String")); + } + MvcExpression.Value get = dynamic.get("hx-get"); + MvcExpression.Value delete = dynamic.get("hx-delete"); + if (get != null || e.hasAttr("hx-get") || !unsafeHtmx(e, dynamic).equals("false")) { + String isGet = + get != null ? get.code + " != null" : Boolean.toString(e.hasAttr("hx-get")); + String isDelete = + delete != null + ? delete.code + " != null" + : Boolean.toString(e.hasAttr("hx-delete")); + dynamic.put( + "hx-params", + new MvcExpression.Value( + HTML + + "htmxParameters(model, " + + isGet + + " || " + + isDelete + + ", " + + paramsLocal + + ")", + "java.lang.String")); + } + String unsafeHtmx = unsafeHtmx(e, dynamic); + if (!unsafeHtmx.equals("false")) { + String headers = e.hasAttr("hx-headers") ? q(e.attr("hx-headers")) : "null"; + dynamic.put( + "hx-headers", + new MvcExpression.Value( + HTML + "htmxHeaders(model, " + unsafeHtmx + ", " + headers + ")", + "java.lang.String")); + } + cacheAttributes(dynamic, snapshots, out); + if (!block && !tag.equals("form")) { + MvcExpression.Value method = dynamic.get("formmethod"); + boolean submitControl = tag.equals("button") || tag.equals("input"); + String unsafeMethod = + submitControl && (method != null || e.hasAttr("formmethod")) + ? "\"post\".equalsIgnoreCase(Html.string(" + + (method == null ? q(e.attr("formmethod")) : method.code) + + "))" + : "false"; + if (!unsafeHtmx.equals("false") || !unsafeMethod.equals("false")) { + MvcExpression.Value owner = dynamic.get("form"); + String ownerCode = + owner != null ? owner.code : e.hasAttr("form") ? q(e.attr("form")) : "null"; + // Sibling fields belong to the enclosing form, including when a fragment + // renders the control. Explicit form ownership also works outside the form. + out.append("if(") + .append(unsafeMethod) + .append(" || ") + .append(unsafeHtmx) + .append(") Html.csrf(out, model, ") + .append(ownerCode) + .append(");\n"); + } + } + if (!block) { + literal(out, "<" + tag); + for (Attribute a : e.attributes()) + if (!a.getKey().startsWith("th:") + && !a.getKey().equals("xmlns:th") + && !dynamic.containsKey(a.getKey())) literal(out, " " + a.html()); + for (Map.Entry a : dynamic.entrySet()) + out.append(HTML) + .append(BOOLEAN.contains(a.getKey()) ? "booleanAttribute" : "attribute") + .append("(out, ") + .append(q(a.getKey())) + .append(", ") + .append(a.getValue().code) + .append(");\n"); + literal(out, ">"); + } + if (tag.equals("form")) { + MvcExpression.Value method = dynamic.get("method"); + String methodCode = + method == null + ? q(e.hasAttr("method") ? e.attr("method") : "get") + : method.code; + // htmx submissions of a form serialize the hidden token as well. + out.append("if(\"post\".equalsIgnoreCase(Html.string(") + .append(methodCode) + .append(")) || ") + .append(unsafeHtmx) + .append(") Html.csrf(out, model);\n"); + } + if (e.hasAttr("th:insert")) include(t, e.attr("th:insert"), env, out); + else if (e.hasAttr("th:text")) + out.append(HTML) + .append("text(out, ") + .append( + optionText != null + ? optionText + : expression(e.attr("th:text"), env, form).code) + .append(");\n"); + else if (e.hasAttr("th:errors")) { + String errorField = field(e.attr("th:errors"), form); + out.append(HTML) + .append("text(out, Html.errors(model, ") + .append(q(form)) + .append(", ") + .append(q(errorField)) + .append("));\n"); + } else if (tag.equals("textarea") && field != null) + out.append(HTML).append("text(out, ").append(fieldValue).append(");\n"); + else for (Node child : e.childNodes()) render(t, child, env, form, select, out); + if (!block && !e.tag().isEmpty()) literal(out, ""); + close(out, braces); + } + + private void cacheAttributes( + Map attributes, + Set snapshots, + StringBuilder out) { + for (Map.Entry entry : attributes.entrySet()) { + MvcExpression.Value value = entry.getValue(); + if (snapshots.contains(value)) continue; + boolean bool = BOOLEAN.contains(entry.getKey()); + String type = bool ? "boolean" : "java.lang.String", name = "attr" + sequence++; + out.append(type) + .append(' ') + .append(name) + .append(" = ") + .append(HTML) + .append(bool ? "truth(" : "attributeValue(") + .append(value.code) + .append(");\n"); + MvcExpression.Value snapshot = new MvcExpression.Value(name, type); + entry.setValue(snapshot); + snapshots.add(snapshot); + } + } + + private static String canonicalAttribute(String name) { + return name.startsWith("data-hx-") ? name.substring(5) : name; + } + + private static String unsafeHtmx(Element e, Map dynamic) { + List conditions = new ArrayList(); + for (String name : Arrays.asList("hx-post", "hx-put", "hx-patch", "hx-delete")) { + MvcExpression.Value value = dynamic.get(name); + if (value != null) conditions.add("((Object)(" + value.code + ") != null)"); + else if (e.hasAttr(name)) return "true"; + } + return conditions.isEmpty() ? "false" : "(" + String.join(" || ", conditions) + ")"; + } + + private void include( + Template current, + String reference, + Map env, + StringBuilder out) { + reference = reference.trim(); + if (reference.startsWith("~{") && reference.endsWith("}")) + reference = reference.substring(2, reference.length() - 1).trim(); + String[] pair = reference.split("\\s*::\\s*", -1); + if (pair.length != 2 + || !pair[0].matches("[A-Za-z0-9_/-]*") + || !pair[1].matches("[A-Za-z][A-Za-z0-9_-]*")) + throw new IllegalArgumentException("Expected a static template :: fragment reference"); + String key = (pair[0].isEmpty() ? current.name : pair[0]) + " :: " + pair[1]; + compile(key); + out.append(methodName(key)) + .append("(out, model, ") + .append(env.get("@hxParams").code) + .append(");\n"); + } + + private MvcExpression.Value expression( + String value, Map env, String form) { + value = value.trim(); + MvcExpression parser = new MvcExpression(ctx, env, helpers); + if (value.startsWith("@{") && value.endsWith("}")) + return url(value.substring(2, value.length() - 1), env, form); + if ((value.startsWith("${") || value.startsWith("*{")) && value.endsWith("}")) { + boolean selection = value.startsWith("*"); + value = value.substring(2, value.length() - 1); + if (selection) { + if (form == null) + throw new IllegalArgumentException("Selection expression requires th:object"); + value = form + "." + value; + } + } + return parser.parse(value); + } + + private MvcExpression.Value url( + String value, Map env, String form) { + int open = value.indexOf('('); + String path = open < 0 ? value : value.substring(0, open); + path = path.trim(); + if (!path.startsWith("/") + || path.startsWith("//") + || path.indexOf('\\') >= 0 + || path.matches(".*[\\s\"<>].*")) + throw new IllegalArgumentException("URL expressions require a local absolute path"); + List> args = + new ArrayList>(); + if (open >= 0) { + if (!value.endsWith(")")) throw new IllegalArgumentException("Invalid URL expression"); + for (String assignment : split(value.substring(open + 1, value.length() - 1), ',')) { + int eq = assignment.indexOf('='); + if (eq < 1) throw new IllegalArgumentException("Expected URL parameter=value"); + String name = assignment.substring(0, eq).trim(); + if (!name.matches("[A-Za-z][A-Za-z0-9_]*")) + throw new IllegalArgumentException("Invalid URL parameter"); + args.add( + new AbstractMap.SimpleImmutableEntry( + name, expression(assignment.substring(eq + 1), env, form))); + } + } + int hash = path.indexOf('#'); + String fragment = hash < 0 ? "" : path.substring(hash); + if (hash >= 0) path = path.substring(0, hash); + Set pathParameters = new HashSet(); + StringBuilder code = new StringBuilder(urlPath(path, args, pathParameters)); + // Consume fragment placeholders before turning the remaining arguments into a query. + String fragmentCode = urlPath(fragment, args, pathParameters); + boolean query = path.contains("?"); + for (Map.Entry arg : args) { + if (pathParameters.contains(arg.getKey())) continue; + code.append(" + ") + .append(q((query ? "&" : "?") + arg.getKey() + "=")) + .append(" + Html.urlPart(") + .append(arg.getValue().code) + .append(')'); + query = true; + } + if (!fragment.isEmpty()) code.append(" + ").append(fragmentCode); + return new MvcExpression.Value("(" + code + ")", "java.lang.String"); + } + + private static String urlPath( + String path, + List> args, + Set pathParameters) { + StringBuilder code = new StringBuilder(); + Matcher placeholders = Pattern.compile("\\{([A-Za-z][A-Za-z0-9_]*)}").matcher(path); + int pos = 0; + while (placeholders.find()) { + String name = placeholders.group(1); + MvcExpression.Value arg = null; + for (Map.Entry entry : args) { + if (!entry.getKey().equals(name)) continue; + if (arg != null) + throw new IllegalArgumentException( + "URL path parameter requires one value: " + name); + arg = entry.getValue(); + } + pathParameters.add(name); + if (arg == null) + throw new IllegalArgumentException( + "Missing URL path parameter " + placeholders.group(1)); + if (code.length() > 0) code.append(" + "); + code.append(q(path.substring(pos, placeholders.start()))) + .append(" + Html.urlPart(") + .append(arg.code) + .append(')'); + pos = placeholders.end(); + } + if (code.length() > 0) code.append(" + "); + code.append(q(path.substring(pos))); + return code.toString(); + } + + private static String field(String value, String form) { + if (form == null || !value.matches("\\*\\{([A-Za-z][A-Za-z0-9_]*|\\*)}")) + throw new IllegalArgumentException( + "Field/errors require a scalar *{field} inside th:object"); + return value.substring(2, value.length() - 1); + } + + static List split(String value, char delimiter) { + List result = new ArrayList(); + int depth = 0, start = 0; + char quote = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (quote != 0) { + if (c == '\\') i++; + else if (c == quote) quote = 0; + } else if (c == '\'' || c == '"') quote = c; + else if (c == '(' || c == '{' || c == '[') depth++; + else if (c == ')' || c == '}' || c == ']') depth--; + else if (c == delimiter && depth == 0) { + result.add(value.substring(start, i).trim()); + start = i + 1; + } + } + result.add(value.substring(start).trim()); + return result; + } + + private void literal(StringBuilder out, String text) { + // Adjacent markup is one byte copy, even when it came from separate DOM nodes. + Matcher tail = + Pattern.compile("out\\.put\\(C([0-9]+), 0, C[0-9]+\\.length\\);\\n$").matcher(out); + if (tail.find()) { + int index = Integer.parseInt(tail.group(1)); + byte[] previous = constants.get(index); + byte[] next = text.getBytes(StandardCharsets.UTF_8); + if (previous.length + next.length <= 4096) { + byte[] combined = new byte[previous.length + next.length]; + System.arraycopy(previous, 0, combined, 0, previous.length); + System.arraycopy(next, 0, combined, previous.length, next.length); + constants.set(index, combined); + return; + } + } + for (int start = 0; start < text.length(); ) { + int end = Math.min(start + 4096, text.length()); + if (end < text.length() && Character.isHighSurrogate(text.charAt(end - 1))) end--; + byte[] bytes = text.substring(start, end).getBytes(StandardCharsets.UTF_8); + int index = constants.size(); + constants.add(bytes); + out.append("out.put(C") + .append(index) + .append(", 0, C") + .append(index) + .append(".length);\n"); + start = end; + } + } + + private static void close(StringBuilder out, int braces) { + for (int i = 0; i < braces; i++) out.append("}\n"); + } + + private static String q(String text) { + return MvcForms.q(text); + } + + private static IllegalArgumentException problem(Template t, Node node, String message) { + return new IllegalArgumentException( + t.name + + ".html:" + + node.sourceRange().start().lineNumber() + + ":" + + node.sourceRange().start().columnNumber() + + ": " + + message); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/MvcTypes.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/MvcTypes.java new file mode 100644 index 00000000000..99b620502fc --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/MvcTypes.java @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ProcessorContext; + +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.signature.SignatureReader; +import org.objectweb.asm.signature.SignatureVisitor; + +import java.util.*; + +/** Resolves property and parent signatures in the context of a declared model type. */ +final class MvcTypes { + final AnnotatedClass cls; + final Map bindings = new LinkedHashMap(); + final List parents = new ArrayList(); + + static AnnotatedClass resolveClass(ProcessorContext ctx, String name) { + for (AnnotatedClass cls : ctx.getClassIndex().values()) + if (name.equals(cls.getSourceName())) return cls; + String internal = name.replace('.', '/'); + AnnotatedClass cls = RestControllerAnnotationProcessor.resolveClass(ctx, internal); + if (cls != null) return cls; + // Dependencies may not be indexed yet. Try nested separators from the right, + // then verify InnerClasses metadata so a top-level '$' is not mistaken for nesting. + int slash; + while ((slash = internal.lastIndexOf('/')) >= 0) { + internal = internal.substring(0, slash) + '$' + internal.substring(slash + 1); + cls = RestControllerAnnotationProcessor.resolveClass(ctx, internal); + if (cls != null && name.equals(cls.getSourceName())) return cls; + } + return null; + } + + static List hierarchy(ProcessorContext ctx, String type) { + List result = new ArrayList(); + Deque pending = new ArrayDeque(); + Set seen = new HashSet(); + pending.add(type); + while (!pending.isEmpty()) { + String owner = pending.removeFirst(); + String name = MvcExpression.raw(owner).replace('.', '/'); + if (!seen.add(name) || "java/lang/Object".equals(name)) continue; + AnnotatedClass cls = MvcTypes.resolveClass(ctx, MvcExpression.raw(owner)); + if (cls == null) continue; + MvcTypes resolved = new MvcTypes(cls, owner); + result.add(resolved); + // Class declarations take precedence over inherited interface defaults. + for (int i = 0; i < resolved.parents.size(); i++) { + String parent = resolved.parents.get(i).toString(); + if (i == 0) pending.addFirst(parent); + else pending.addLast(parent); + } + } + return result; + } + + static String sourceType(ProcessorContext ctx, String type) { + java.util.regex.Matcher words = + java.util.regex.Pattern.compile("[A-Za-z_$][A-Za-z0-9_$.]*").matcher(type); + StringBuffer result = new StringBuffer(); + while (words.find()) { + String word = words.group(); + AnnotatedClass cls = word.indexOf('.') < 0 ? null : resolveClass(ctx, word); + words.appendReplacement( + result, + java.util.regex.Matcher.quoteReplacement( + cls == null ? word : cls.getSourceName())); + } + words.appendTail(result); + return result.toString(); + } + + MvcTypes(AnnotatedClass cls, String owner) { + this.cls = cls; + final List arguments = arguments(owner); + if (cls.getSignature() == null) { + if (cls.getSuperInternalName() != null) + parents.add(new StringBuilder(cls.getSuperInternalName().replace('/', '.'))); + for (String parent : cls.getInterfaceInternalNames()) + parents.add(new StringBuilder(parent.replace('/', '.'))); + } else { + new SignatureReader(cls.getSignature()) + .accept( + new SignatureVisitor(Opcodes.ASM9) { + private int index; + + @Override + public void visitFormalTypeParameter(String name) { + bindings.put( + name, + index < arguments.size() + ? arguments.get(index) + : "java.lang.Object"); + index++; + } + + @Override + public SignatureVisitor visitClassBound() { + return ignored(); + } + + @Override + public SignatureVisitor visitInterfaceBound() { + return ignored(); + } + + @Override + public SignatureVisitor visitSuperclass() { + return parent(); + } + + @Override + public SignatureVisitor visitInterface() { + return parent(); + } + + private SignatureVisitor parent() { + StringBuilder out = new StringBuilder(); + parents.add(out); + return new JavaType(out, bindings); + } + }); + } + } + + String member(Type erased, String signature, boolean method) { + return member(erased, signature, method, -1); + } + + String parameter(Type erased, String signature, int index) { + return member(erased, signature, true, index); + } + + private String member(Type erased, String signature, boolean method, final int parameterIndex) { + if (signature == null) return RestClientAnnotationProcessor.javaTypeFor(erased, null); + final StringBuilder out = new StringBuilder(); + final Map scope = new LinkedHashMap(bindings); + SignatureReader reader = new SignatureReader(signature); + if (!method) reader.acceptType(new JavaType(out, scope)); + else + reader.accept( + new SignatureVisitor(Opcodes.ASM9) { + private int parameter; + + // A method type parameter shadows one with the same name on the class. + @Override + public void visitFormalTypeParameter(String name) { + scope.put(name, "java.lang.Object"); + } + + @Override + public SignatureVisitor visitClassBound() { + return ignored(); + } + + @Override + public SignatureVisitor visitInterfaceBound() { + return ignored(); + } + + @Override + public SignatureVisitor visitParameterType() { + return parameter++ == parameterIndex + ? new JavaType(out, scope) + : ignored(); + } + + @Override + public SignatureVisitor visitExceptionType() { + return ignored(); + } + + @Override + public SignatureVisitor visitReturnType() { + return parameterIndex < 0 ? new JavaType(out, scope) : ignored(); + } + }); + return out.toString(); + } + + private static SignatureVisitor ignored() { + return new SignatureVisitor(Opcodes.ASM9) {}; + } + + private static List arguments(String type) { + List result = new ArrayList(); + int start = type.indexOf('<'); + if (start < 0) return result; + int depth = 0; + start++; + for (int i = start; i < type.length(); i++) { + char c = type.charAt(i); + if ((c == ',' || c == '>') && depth == 0) { + result.add(type.substring(start, i).trim()); + start = i + 1; + if (c == '>') break; + } else if (c == '<') depth++; + else if (c == '>') depth--; + } + return result; + } + + private static final class JavaType extends SignatureVisitor { + private final StringBuilder out; + private final Map bindings; + private final String suffix; + private boolean arguments; + + JavaType(StringBuilder out, Map bindings) { + this(out, bindings, ""); + } + + JavaType(StringBuilder out, Map bindings, String suffix) { + super(Opcodes.ASM9); + this.out = out; + this.bindings = bindings; + this.suffix = suffix; + } + + @Override + public void visitBaseType(char descriptor) { + out.append(Type.getType(String.valueOf(descriptor)).getClassName()).append(suffix); + } + + @Override + public void visitTypeVariable(String name) { + String type = bindings.get(name); + out.append(type == null ? "java.lang.Object" : type).append(suffix); + } + + @Override + public SignatureVisitor visitArrayType() { + return new JavaType(out, bindings, "[]" + suffix); + } + + @Override + public void visitClassType(String name) { + out.append(name.replace('/', '.')); + } + + @Override + public void visitInnerClassType(String name) { + closeArguments(); + out.append('.').append(name); + } + + private void argument() { + out.append(arguments ? ',' : '<'); + arguments = true; + } + + @Override + public void visitTypeArgument() { + argument(); + out.append('?'); + } + + @Override + public SignatureVisitor visitTypeArgument(char wildcard) { + argument(); + if (wildcard == EXTENDS) out.append("? extends "); + else if (wildcard == SUPER) out.append("? super "); + return new JavaType(out, bindings); + } + + private void closeArguments() { + if (arguments) { + out.append('>'); + arguments = false; + } + } + + @Override + public void visitEnd() { + closeArguments(); + out.append(suffix); + } + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java index bec331cb71e..6b7909e0807 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java @@ -73,6 +73,9 @@ public final class RestControllerAnnotationProcessor extends AbstractAnnotationProcessor { private static final String PKG = "Lcom/codename1/backend/annotations/"; + private static final String MVC_CONTROLLER = PKG + "Controller;"; + private static final String MODEL_ATTRIBUTE = PKG + "ModelAttribute;"; + private static final String MVC = "com.codename1.backend.mvc."; private static final String CONTROLLER = PKG + "RestController;"; private static final String REQUEST_MAPPING = PKG + "RequestMapping;"; private static final String PATH_VARIABLE = PKG + "PathVariable;"; @@ -235,6 +238,7 @@ public void setDevTools(boolean devTools) { private final Map routeOwners = new LinkedHashMap(); private static final class Controller { + boolean mvc; String binaryName; /** * The name to WRITE, which is the binary one until the controller is a @@ -255,6 +259,7 @@ private static final class Route { String pattern; String javaMethod; String returnJavaType; + boolean view; int status; List params = new ArrayList(); /** The literal bytes before the first `{`; the whole pattern when there is none. */ @@ -287,6 +292,7 @@ private static final class Param { * one; null when the body binds as parsed. */ String codecRead; + String formBinding; } /// {AnnotatedClass, description} of every handler parameter the security @@ -308,6 +314,7 @@ private BackendJsonCodecs codecs(ProcessorContext ctx) { public Set getAnnotationDescriptors() { Set out = new java.util.LinkedHashSet(); out.add(CONTROLLER); + out.add(MVC_CONTROLLER); out.add(WEBSOCKET_MAPPING); return out; } @@ -320,7 +327,7 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces // controller and a websocket endpoint, and refusing that would be an // arbitrary rule rather than a real constraint. } - if (cls.getClassAnnotation(CONTROLLER) == null) { + if (cls.getClassAnnotation(CONTROLLER) == null && cls.getClassAnnotation(MVC_CONTROLLER) == null) { return; } // A CLASS WHOSE SOURCE IS GONE is not a controller any more. Maven leaves @@ -341,7 +348,12 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces ctx.error(cls, "@RestController must be a concrete class: " + cls.getBinaryName()); return; } + if (cls.getClassAnnotation(CONTROLLER) != null && cls.getClassAnnotation(MVC_CONTROLLER) != null) { + ctx.error(cls, "Choose @Controller or @RestController, not both"); + return; + } Controller controller = new Controller(); + controller.mvc = cls.getClassAnnotation(MVC_CONTROLLER) != null; controller.binaryName = cls.getBinaryName(); controller.sourceName = cls.getSourceName(); controller.packageName = RestClientAnnotationProcessor.packageOf(controller.binaryName); @@ -785,6 +797,7 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St String[] genericParams = RestClientAnnotationProcessor.parseGenericParameterSignatures( m.getSignature(), paramTypes.length); List> paramAnnotations = m.getParameterAnnotations(); + Set formNames = new LinkedHashSet(); for (int i = 0; i < paramTypes.length; i++) { Param p = new Param(); p.javaType = RestClientAnnotationProcessor.javaTypeFor(paramTypes[i], null); @@ -804,6 +817,7 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St AnnotationValues requestBody = annotations.get(REQUEST_BODY); AnnotationValues requestPart = annotations.get(REQUEST_PART); AnnotationValues principal = annotations.get(AUTHENTICATION_PRINCIPAL); + AnnotationValues form = annotations.get(MODEL_ATTRIBUTE); // EXACTLY one. The chain below is priority-ordered, so a parameter // carrying both @RequestHeader("Authorization") and @RequestParam("token") // silently bound whichever came first and read from a source the @@ -812,7 +826,7 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St // caller writes. The contract client processor already refuses this. int bindings = (pathVariable != null ? 1 : 0) + (requestParam != null ? 1 : 0) + (requestHeader != null ? 1 : 0) + (requestBody != null ? 1 : 0) - + (requestPart != null ? 1 : 0) + (principal != null ? 1 : 0); + + (requestPart != null ? 1 : 0) + (principal != null ? 1 : 0) + (form != null ? 1 : 0); if (bindings > 1) { ctx.error(cls, "Parameter " + (i + 1) + " of " + cls.getBinaryName() + "." + m.getName() + " carries more than one binding annotation. One " @@ -821,7 +835,25 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St + "@AuthenticationPrincipal, and drop the others."); return null; } - if (pathVariable != null) { + if (form != null) { + p.kind = "FORM"; + p.name = form.getStringOrDefault("value", ""); + p.local = "cn1Form" + i; + if (!p.name.matches("[A-Za-z][A-Za-z0-9_]*")) { ctx.error(cls, "@ModelAttribute requires a simple nonempty name"); return null; } + if (!formNames.add(p.name)) { + ctx.error(cls, "Duplicate @ModelAttribute name '" + p.name + "' on " + + cls.getBinaryName() + "." + m.getName() + + "; each form parameter on a route needs a distinct name"); + return null; + } + try { p.formBinding = MvcForms.binding(ctx, p.javaType, p.name, p.local); } + catch (IllegalArgumentException error) { ctx.error(cls, error.getMessage()); return null; } + } else if ((MVC + "Model").equals(p.javaType)) { + p.kind = "MODEL"; + } else if ((MVC + "BindingResult").equals(p.javaType)) { + if (i == 0 || !"FORM".equals(route.params.get(i - 1).kind)) { ctx.error(cls, "BindingResult must immediately follow @ModelAttribute"); return null; } + p.kind = "ERRORS"; p.local = route.params.get(i - 1).local + "Errors"; + } else if (pathVariable != null) { p.kind = "PATH"; p.name = pathVariable.getStringOrDefault("value", ""); p.defaultValue = pathVariable.getStringOrDefault("defaultValue", ""); @@ -975,7 +1007,7 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St + "generates the codecs."); return null; } - if (!"REQUEST".equals(p.kind) && !"PART".equals(p.kind) && !isSecurityKind(p.kind) + if (!"REQUEST".equals(p.kind) && !"PART".equals(p.kind) && !isSecurityKind(p.kind) && !isMvcKind(p.kind) && !isBindable(p.javaType, p.kind)) { ctx.error(cls, "Cannot bind " + p.javaType + " from the request on " + cls.getBinaryName() + "." + m.getName() + ". Path, query and " @@ -1029,7 +1061,11 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St + "start the work and return an id to ask about it by."); return null; } - if (!isEncodableReturn(route.returnJavaType, ctx)) { + route.view = cls.getClassAnnotation(MVC_CONTROLLER) != null + && cls.getClassAnnotation(PKG + "ResponseBody;") == null + && m.getAnnotation(PKG + "ResponseBody;") == null + && ("java.lang.String".equals(route.returnJavaType) || (MVC + "ModelAndView").equals(route.returnJavaType)); + if (!route.view && !isEncodableReturn(route.returnJavaType, ctx)) { // One of the application's classes, or a container of them: written // through a codec the build generates for it, as Jackson would write // it for a Spring controller. @@ -1478,6 +1514,20 @@ public void finish(ProcessorContext ctx) throws ProcessingException { } sources.putAll(codecSources); } + boolean hasViews = false; + for (Controller controller : controllers.values()) for (Route route : controller.routes) hasViews |= route.view; + if (hasViews || hasMvcControllers()) { + try { + Map mvcSources = hasViews ? new MvcTemplates(ctx).sources() : new LinkedHashMap(); + mvcSources.putAll(MvcAssets.sources(MvcTemplates.projectDirectory(ctx))); + for (String generated : mvcSources.keySet()) if (isNotOurOwnOutput(ctx, generated)) { + ctx.error("Generated MVC class would overwrite " + generated); return; + } + sources.putAll(mvcSources); + ctx.setAttribute("cn1.backend.mvc", Boolean.TRUE); + } + catch (IllegalArgumentException error) { ctx.error(error.getMessage()); return; } + } daos = hasGeneratedDaos(ctx); sources.put(wiring, generateWiring(entryPackage)); sources.put(bootstrap, generateBootstrap(entryPackage)); @@ -1488,6 +1538,7 @@ public void finish(ProcessorContext ctx) throws ProcessingException { cp.add(new File(element)); } JavaSourceCompiler.compile(sources, ctx.getOutputClassDir(), cp); + MvcAssets.removeObsoleteClasses(ctx.getOutputClassDir(), sources.keySet()); ctx.emitResource(MAIN_CLASS_RESOURCE, asciiBytes(bootstrap)); ctx.emitResource(WIRING_RESOURCE, asciiBytes(wiringRecord(entryPackage))); } catch (IOException ioe) { @@ -1655,6 +1706,17 @@ private static void emitRoute(StringBuilder sb, Route route, int index, Controll emitScalarGuards(sb, route, pad); emitBodyLocals(sb, route, pad); + boolean needsModel = route.view; + for (Param p : route.params) needsModel |= isMvcKind(p.kind); + if (needsModel) sb.append(pad).append("com.codename1.backend.mvc.Model cn1Model = com.codename1.backend.mvc.Html.model(request);\n"); + for (int i = 0; i < route.params.size(); i++) { + Param p = route.params.get(i); + if ("FORM".equals(p.kind)) { + appendIndented(sb, p.formBinding, pad); + if (i + 1 >= route.params.size() || !"ERRORS".equals(route.params.get(i + 1).kind)) + sb.append(pad).append("if (").append(p.local).append("Errors.hasErrors()) return com.codename1.backend.HttpServer.Response.text(400, \"Invalid form\");\n"); + } + } StringBuilder args = new StringBuilder(); boolean principalRead = false; for (int i = 0; i < route.params.size(); i++) { @@ -1672,7 +1734,15 @@ private static void emitRoute(StringBuilder sb, Route route, int index, Controll } String call = "impl." + route.javaMethod + "(" + args + ")"; - if ("void".equals(route.returnJavaType)) { + if (route.view) { + if ((MVC + "ModelAndView").equals(route.returnJavaType)) { + sb.append(pad).append(MVC).append("ModelAndView result = ").append(call).append(";\n"); + sb.append(pad).append("if (result == null) return com.codename1.backend.HttpServer.Response.text(404, \"\");\n"); + sb.append(pad).append("cn1Model.addAllAttributes(result.getModel());\n"); + sb.append(pad).append("String view = result.getViewName();\n"); + } else sb.append(pad).append("String view = ").append(call).append(";\n"); + sb.append(pad).append("return com.codename1.generated.mvc.Views.respond(request, view, cn1Model, ").append(route.status).append(");\n"); + } else if ("void".equals(route.returnJavaType)) { sb.append(pad).append(call).append(";\n"); sb.append(pad).append("return request.respond(").append(route.status) .append(", \"text/plain\", EMPTY);\n"); @@ -2359,11 +2429,17 @@ private static String bodyElementType(String genericJavaType) { /// Whether a parameter is filled from the security layer rather than read /// out of the request: who is signed in, their principal, or the CSRF token. + private static boolean isMvcKind(String kind) { + return "FORM".equals(kind) || "MODEL".equals(kind) || "ERRORS".equals(kind); + } + private static boolean isSecurityKind(String kind) { return "AUTHENTICATION".equals(kind) || "PRINCIPAL".equals(kind) || "CSRF".equals(kind); } private static String argumentExpression(Param p) { + if ("MODEL".equals(p.kind)) return "cn1Model"; + if ("FORM".equals(p.kind) || "ERRORS".equals(p.kind)) return p.local; if ("REQUEST".equals(p.kind)) { return "request"; } @@ -2974,10 +3050,16 @@ String generateBootstrap(String packageName) { return sb.toString(); } + private boolean hasMvcControllers() { + for (Controller controller : controllers.values()) if (controller.mvc) return true; + return false; + } + /// What [#WIRING_RESOURCE] holds for this build. String wiringRecord(String packageName) { StringBuilder sb = new StringBuilder(); sb.append("package\t").append(packageName).append('\n'); + if (hasMvcControllers()) sb.append("mvc-assets\ttrue\n"); for (Controller c : controllers.values()) { sb.append("router\t").append(c.binaryName).append('\t') .append(qualify(c.packageName, c.routerSimpleName)).append('\n'); @@ -3360,7 +3442,7 @@ private static String stringArrayLiteral(List values) { return sb.append("}").toString(); } - private static String quote(String value) { + static String quote(String value) { StringBuilder sb = new StringBuilder("\""); for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java index 84ee860aed2..118b51ff467 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java @@ -923,6 +923,34 @@ public void aMockUserThatCannotBeBuiltIsABuildError() throws Exception { } } + @Test + public void mvcTestContextServesCompiledViewsAndEmbeddedAssets() throws Exception { + File project = tmp.newFolder(); + File template = new File(project, "src/main/resources/templates/page.html"); + template.getParentFile().mkdirs(); + Files.write(template.toPath(), "

Hello MVC

".getBytes("UTF-8")); + File asset = new File(project, "src/main/resources/static/test.txt"); + asset.getParentFile().mkdirs(); + Files.write(asset.toPath(), "embedded".getBytes("UTF-8")); + File classes = mainBuild(Collections.singletonMap("com.example.Pages", MAIN + + "@Controller public class Pages { @GetMapping(\"/page\") public String page(){return \"page\";} }"), project); + Map sources = new LinkedHashMap(); + sources.put("com.example.PagesTest", "package com.example; " + + "import com.codename1.backend.annotations.*; import com.codename1.backend.test.*; " + + "import static com.codename1.backend.test.MockMvcRequestBuilders.*; " + + "import static com.codename1.backend.test.MockMvcResultMatchers.*; " + + "@BackendTest public class PagesTest { @Autowired MockMvc mvc; " + + "@org.junit.jupiter.api.Test public void works() throws Exception { " + + "mvc.perform(get(\"/page\")).andExpect(status().isOk()).andExpect(content().string(\"

Hello MVC

\")); " + + "mvc.perform(get(\"/static/test.txt\")).andExpect(status().isOk()).andExpect(content().string(\"embedded\")); } }"); + File tests = testBuild(classes, sources); + URLClassLoader loader = new URLClassLoader(new URL[]{tests.toURI().toURL(), classes.toURI().toURL()},getClass().getClassLoader()); + TestContext context = (TestContext)loader.loadClass("com.example.PagesTestCn1TestContext").newInstance(); + Object test = loader.loadClass("com.example.PagesTest").newInstance(); + context.inject(test,TestContexts.acquire(context)); + invoke(test,"works"); + } + private static void invoke(Object test, String name) throws Exception { java.lang.reflect.Method m = test.getClass().getDeclaredMethod(name); m.setAccessible(true); @@ -942,6 +970,10 @@ private static void invoke(Object test, String name) throws Exception { /// Compiles and processes the application the way process-annotations does, /// wiring record included. private File mainBuild(Map sources) throws Exception { + return mainBuild(sources, tmp.newFolder()); + } + + private File mainBuild(Map sources, File project) throws Exception { File classes = tmp.newFolder(); JavaSourceCompiler.compile(sources, classes, classpath()); Map index = ClassScanner.scan(classes); @@ -950,7 +982,7 @@ private File mainBuild(Map sources) throws Exception { cp.add(f.getAbsolutePath()); } ProcessorContext ctx = new ProcessorContext(classes, tmp.newFolder(), index, - new SystemStreamLog(), tmp.newFolder(), new Properties(), null, + new SystemStreamLog(), project, new Properties(), null, Collections.emptyList(), "UTF-8", cp); BackendBeanAnnotationProcessor beans = new BackendBeanAnnotationProcessor(); RestControllerAnnotationProcessor proc = new RestControllerAnnotationProcessor(); diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/MvcTemplatesTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/MvcTemplatesTest.java new file mode 100644 index 00000000000..ab3de49ad1c --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/MvcTemplatesTest.java @@ -0,0 +1,2854 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.processors; + +import static org.junit.Assert.*; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.mvc.*; +import com.codename1.build.SystemStreamLog; +import com.codename1.maven.annotations.*; + +import org.junit.*; +import org.junit.rules.TemporaryFolder; + +import java.io.*; +import java.lang.reflect.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import java.util.concurrent.*; + +/** Runs compiled views and generated MVC routes, not snapshots of generated source. */ +public class MvcTemplatesTest { + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + private File project, classes; + private ProcessorContext context; + private URLClassLoader loader; + private static final String TYPES = + "package sample; public class Product { public String name; public int quantity; public" + + " boolean active; public Product() {} public String getLabel() { return name; }" + + " }"; + private static final String DECL = + ""; + + private void setup() throws Exception { + project = tmp.newFolder(); + classes = new File(project, "target/classes"); + assertTrue(classes.mkdirs()); + JavaSourceCompiler.compile( + Collections.singletonMap("sample.Product", TYPES), classes, classpath()); + context = + new ProcessorContext( + classes, + tmp.newFolder(), + ClassScanner.scan(classes), + new SystemStreamLog(), + project, + new Properties(), + null, + Collections.emptyList(), + "UTF-8", + Arrays.asList(classpath().get(0).getPath(), classes.getPath())); + } + + private static List classpath() throws Exception { + return Collections.singletonList( + new File( + HttpServer.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI())); + } + + private void template(String name, String text) throws Exception { + File file = new File(project, "src/main/resources/templates/" + name + ".html"); + file.getParentFile().mkdirs(); + Files.write(file.toPath(), text.getBytes(StandardCharsets.UTF_8)); + } + + private void compile() throws Exception { + Map sources = new MvcTemplates(context).sources(); + List cp = new ArrayList(classpath()); + cp.add(classes); + JavaSourceCompiler.compile(sources, classes, cp); + loader = + new URLClassLoader( + new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); + } + + private Object product(String name, int quantity) throws Exception { + Object p = loader.loadClass("sample.Product").newInstance(); + p.getClass().getField("name").set(p, name); + p.getClass().getField("quantity").setInt(p, quantity); + return p; + } + + private String render(String view, Model model) throws Exception { + try { + return new String( + (byte[]) + loader.loadClass("com.codename1.generated.mvc.Views") + .getMethod("render", String.class, Model.class) + .invoke(null, view, model), + StandardCharsets.UTF_8); + } catch (InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + @Test + public void typedRenderingFragmentsUnicodeEscapingAndUrls() throws Exception { + setup(); + template( + "products", + DECL + + "
  • 0}\" th:classappend=\"${s.first ?" + + " 'first' : 'rest'}\">sample
"); + template("layout", "
Catalog
"); + compile(); + Object p = product("<ืฉืœื•ื & \"๐Ÿ˜€\">", 2); + Model m = new Model().addAttribute("products", Arrays.asList(p)); + String html = render("products", m); + assertTrue(html, html.contains("
Catalog
")); + assertTrue(html, html.contains("<ืฉืœื•ื & "๐Ÿ˜€">")); + assertTrue(html, html.contains("/products/2?q=%3C")); + assertFalse(html, html.contains("th:")); + String fragment = render("products :: rows", m); + assertTrue(fragment, fragment.startsWith("
    ")); + assertFalse(fragment, fragment.contains("")); + assertFalse(fragment, fragment.contains("Catalog")); + try { + render("products :: rows", new Model()); + fail(); + } catch (IllegalStateException expected) { + assertTrue(expected.getMessage().contains("products")); + } + try { + render( + "products :: rows", + new Model().addAttribute("products", Arrays.asList("wrong"))); + fail(); + } catch (IllegalStateException expected) { + assertTrue(expected.getMessage().contains("element type")); + } + } + + @Test + public void formsPreserveRejectedValuesAndErrors() throws Exception { + setup(); + template( + "edit", + DECL + + "
    "); + compile(); + BindingResult errors = new BindingResult(); + errors.submitted("quantity", "oops <"); + errors.rejectValue("quantity", "Must be a number <"); + Model m = + new Model() + .addAttribute("product", product("B", 3)) + .addAttribute("BindingResult.product", errors) + .addAttribute( + "_csrf", + new com.codename1.backend.security.CsrfToken() { + public String getToken() { + return "token<&"; + } + + public String getHeaderName() { + return "X-CSRF-TOKEN"; + } + + public String getParameterName() { + return "_csrf"; + } + }); + String html = render("edit", m); + assertTrue(html, html.contains("value=\"oops <\"")); + assertTrue(html, html.contains("Must be a number <")); + assertTrue(html, html.contains("value=\"B\" selected=\"selected\"")); + assertTrue(html, html.contains("token<&")); + assertTrue(html, html.contains("name=\"_active\"")); + template("dynamic", "
    "); + compile(); + assertTrue(render("dynamic", m).contains("name=\"_csrf\"")); + } + + @Test + public void rejectsBadTemplatesAtBuildTime() throws Exception { + for (String html : + Arrays.asList( + DECL + "

    ", + "

    ", + "", + "

    ", + "
    ", + DECL + "

    ")) { + setup(); + template("bad", html); + try { + new MvcTemplates(context).sources(); + fail(html); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("bad") + || expected.getMessage().contains("Recursive")); + } + } + } + + @Test + public void changesAndDeletionsReplaceRegistry() throws Exception { + setup(); + template("a", "

    A

    "); + template("b", "

    B

    "); + compile(); + assertTrue(render("b", new Model()).contains("B")); + Files.delete(new File(project, "src/main/resources/templates/b.html").toPath()); + template("a", "

    New

    "); + compile(); + assertTrue(render("a", new Model()).contains("New")); + try { + render("b", new Model()); + fail(); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("Unknown compiled view")); + } + } + + @Test + public void rendersConcurrentlyWithoutSharingModels() throws Exception { + setup(); + template("a", "

    "); + compile(); + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + List> tasks = new ArrayList>(); + for (int i = 0; i < 40; i++) { + final int id = i; + tasks.add( + pool.submit( + new Callable() { + public String call() throws Exception { + return render( + "a", new Model().addAttribute("name", "n" + id)); + } + })); + } + for (int i = 0; i < tasks.size(); i++) + assertTrue(tasks.get(i).get().contains(">n" + i + "<")); + } finally { + pool.shutdownNow(); + } + } + + @Test + public void generatedControllerBindsFormsRendersAndPreservesRestBodies() throws Exception { + setup(); + template( + "edit", + DECL + + "
    "); + String source = + "package sample; import com.codename1.backend.*; import" + + " com.codename1.backend.annotations.*; import com.codename1.backend.mvc.*;" + + " @Controller public class Pages {@GetMapping(\"/edit\") public String" + + " edit(Model model) {Product p=new" + + " Product();p.name=\"Hello\";model.addAttribute(\"product\",p);return" + + " \"edit\";}@PostMapping(\"/edit\") public String" + + " save(@ModelAttribute(\"product\") Product product, BindingResult errors)" + + " {if(product.quantity<1)errors.rejectValue(\"quantity\",\"Positive quantity" + + " required\");return" + + " errors.hasErrors()?\"edit\":\"redirect:/edit\";}@GetMapping(\"/body\")" + + " @ResponseBody public String body(){return" + + " \"literal\";}@GetMapping(\"/model\") public ModelAndView view(){Product" + + " p=new Product();p.name=\"MV\";return new" + + " ModelAndView(\"edit\").addObject(\"product\",p);} }"; + List cp = new ArrayList(classpath()); + cp.add(classes); + JavaSourceCompiler.compile(Collections.singletonMap("sample.Pages", source), classes, cp); + context = + new ProcessorContext( + classes, + tmp.newFolder(), + ClassScanner.scan(classes), + new SystemStreamLog(), + project, + new Properties(), + null, + Collections.emptyList(), + "UTF-8", + Arrays.asList(cp.get(0).getPath(), classes.getPath())); + RestControllerAnnotationProcessor processor = new RestControllerAnnotationProcessor(); + processor.start(context); + for (AnnotatedClass cls : context.getClassIndex().values()) + processor.processClass(cls, context); + processor.finish(context); + assertFalse(context.getErrors().toString(), context.hasErrors()); + loader = + new URLClassLoader( + new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); + Class controller = loader.loadClass("sample.Pages"), + router = loader.loadClass("sample.PagesRouter"); + Object handler = router.getConstructor(controller).newInstance(controller.newInstance()); + Method handle = router.getMethod("handle", HttpServer.Request.class); + assertTrue( + body(handle.invoke(handler, request("GET", "/edit", null, false))) + .contains("Hello")); + assertEquals("literal", body(handle.invoke(handler, request("GET", "/body", null, false)))); + assertTrue( + body(handle.invoke(handler, request("GET", "/model", null, false))).contains("MV")); + Object invalid = + handle.invoke( + handler, request("POST", "/edit", "name=A&quantity=no&_active=on", false)); + assertTrue(body(invalid).contains("value=\"no\"")); + assertTrue(body(invalid).contains("Invalid value")); + Object saved = + handle.invoke( + handler, request("POST", "/edit", "name=A&quantity=2&_active=on", false)); + assertEquals(303, field(saved, "status")); + Object hx = + handle.invoke( + handler, request("POST", "/edit", "name=A&quantity=2&_active=on", true)); + assertEquals(200, field(hx, "status")); + } + + @Test + public void packagedAssetsAreExactAndTemplatesArePrivate() throws Exception { + setup(); + File asset = new File(project, "src/main/resources/static/probe.bin"); + asset.getParentFile().mkdirs(); + byte[] data = new byte[3000]; + for (int i = 0; i < data.length; i++) data[i] = (byte) i; + Files.write(asset.toPath(), data); + JavaSourceCompiler.compile(MvcAssets.sources(project), classes, classpath()); + loader = + new URLClassLoader( + new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); + HttpServer.Handler assets = + (HttpServer.Handler) + loader.loadClass("com.codename1.generated.mvc.Assets").newInstance(); + assertArrayEquals( + data, + (byte[]) + field( + assets.handle(request("GET", "/static/probe.bin?v=1", null, false)), + "body")); + assertNull(assets.handle(request("GET", "/templates/edit.html", null, false))); + assertNull(assets.handle(request("GET", "/static/../templates/edit.html", null, false))); + assertNull(assets.handle(request("POST", "/static/probe.bin", null, false))); + } + + @Test + public void nullsBooleanAttributesAndUnsafeContexts() throws Exception { + setup(); + template( + "a", + DECL + + "

    "); + compile(); + String html = render("a", new Model().addAttribute("product", null)); + assertTrue(html, html.contains("

    ")); + assertFalse(html, html.contains("disabled")); + template("a", DECL + ""); + try { + new MvcTemplates(context).sources(); + fail(); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("script/style")); + } + for (String url : + Arrays.asList( + "javascript:alert(1)", + "JaVaScRiPt:alert(1)", + "data:text/html,test", + "java\nscript:alert(1)")) { + try { + Html.attribute(new com.codename1.backend.ByteSink(16), "href", url); + fail(url); + } catch (IllegalArgumentException expected) { + } + } + assertTrue(Html.checked("on", "true")); + assertFalse(Html.checked(Boolean.TRUE, "false")); + } + + @Test + public void urlSchemesAreIndependentOfDefaultLocale() { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(new Locale("tr", "TR")); + for (String url : + Arrays.asList( + "HTTPS://example.test/", + "MaIlTo:test@example.test", + "TEL:123", + "/products")) { + Html.safeUrl(url); + } + for (String url : + Arrays.asList( + "JAVASCRIPT:alert(1)", + "ma\u0131lto:test@example.test", + "ma\u0130lto:test@example.test")) { + try { + Html.safeUrl(url); + fail(url); + } catch (IllegalArgumentException expected) { + } + } + } finally { + Locale.setDefault(previous); + } + } + + @Test + public void modelErrorsAndUnsafeRedirects() throws Exception { + setup(); + template("a", DECL + "

    "); + compile(); + try { + render("a", new Model().addAttribute("product", "wrong")); + fail(); + } catch (IllegalStateException expected) { + assertTrue(expected.getMessage().contains("Wrong model type")); + } + for (String location : + Arrays.asList( + "//evil.test/", + "https://evil.test/", + "/\\evil.test/", + "/ok\r\nLocation: evil")) { + try { + Htmx.redirect(location); + fail(location); + } catch (IllegalArgumentException expected) { + } + } + Object response = Htmx.redirect("/products"); + assertEquals(200, field(response, "status")); + assertTrue(field(response, "extraHeaders").toString().contains("/products")); + } + + @Test + public void indexedModelsCheckElementTypesBeforePropertyAccess() throws Exception { + setup(); + template("a", DECL + "

    "); + compile(); + assertTrue( + render( + "a", + new Model() + .addAttribute( + "products", Arrays.asList(product("Indexed", 1)))) + .contains("Indexed")); + try { + render("a", new Model().addAttribute("products", Arrays.asList("wrong"))); + fail(); + } catch (IllegalStateException expected) { + assertTrue(expected.getMessage().contains("indexed element")); + } + } + + private void fixtureSources(Map sources) throws Exception { + List cp = new ArrayList(classpath()); + cp.add(classes); + JavaSourceCompiler.compile(sources, classes, cp); + context = + new ProcessorContext( + classes, + tmp.newFolder(), + ClassScanner.scan(classes), + new SystemStreamLog(), + project, + new Properties(), + null, + Collections.emptyList(), + "UTF-8", + Arrays.asList(cp.get(0).getPath(), classes.getPath())); + } + + private HttpServer.Handler controller(String source) throws Exception { + fixtureSources(Collections.singletonMap("sample.Pages", source)); + RestControllerAnnotationProcessor processor = new RestControllerAnnotationProcessor(); + processor.start(context); + for (AnnotatedClass cls : context.getClassIndex().values()) + processor.processClass(cls, context); + processor.finish(context); + assertFalse(context.getErrors().toString(), context.hasErrors()); + loader = + new URLClassLoader( + new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); + Class pages = loader.loadClass("sample.Pages"); + return (HttpServer.Handler) + loader.loadClass("sample.PagesRouter") + .getConstructor(pages) + .newInstance(pages.newInstance()); + } + + @Test + public void mixedCaseDynamicAttributesUseCanonicalSecurityChecks() throws Exception { + setup(); + for (String attribute : + Arrays.asList("HX-ON:click", "Hx-Vals", "HX-HEADERS", "OnClick", "TH:text")) { + template("a", "
    "); + try { + new MvcTemplates(context).sources(); + fail(attribute); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("Unsupported dynamic attribute")); + } + } + template( + "a", + "link"); + compile(); + String html = render("a", new Model().addAttribute("url", "/new")); + assertTrue(html, html.contains("href=\"/new\"")); + assertFalse(html, html.contains("/old")); + try { + render("a", new Model().addAttribute("url", "javascript:alert(1)")); + fail("Uppercase HREF must still validate URLs"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("Unsafe URL")); + } + try { + Html.attribute(new com.codename1.backend.ByteSink(16), "HREF", "javascript:alert(1)"); + fail("The runtime helper must validate uppercase URL attributes too"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("Unsafe URL")); + } + } + + @Test + public void boxedNumbersCompareByValueWithNullsAndJavaPromotion() throws Exception { + setup(); + template( + "a", + ""); + template( + "mixed", + ""); + template( + "longs", + "" + + ""); + template( + "floating", + "" + + ""); + compile(); + Model model = + new Model() + .addAttribute("left", new Integer(1000)) + .addAttribute("right", new Integer(1000)); + assertTrue(render("a", model).contains("truefalse")); + model.addAttribute("right", 1001); + assertTrue(render("a", model).contains("falsetrue")); + model.addAttribute("left", null); + assertTrue(render("a", model).contains("false")); + model.addAttribute("right", null); + assertTrue(render("a", model).contains("true")); + model.addAttribute("left", 1000).addAttribute("right", 1000L); + assertTrue(render("mixed", model).contains("truetrue")); + model.addAttribute("left", 9007199254740992L).addAttribute("right", 9007199254740993L); + assertTrue(render("longs", model).contains("false")); + model.addAttribute("left", 1.5f).addAttribute("right", 1.5d); + assertTrue(render("floating", model).contains("true")); + model.addAttribute("left", Float.NaN).addAttribute("right", Double.NaN); + assertTrue(render("floating", model).contains("false")); + } + + @Test + public void iterationParityMatchesThymeleafAndCssChildCounting() throws Exception { + setup(); + template( + "a", + DECL + + "

    "); + compile(); + String html = + render( + "a", + new Model() + .addAttribute( + "products", + Arrays.asList(product("a", 1), product("b", 2)))); + assertTrue( + html, + html.contains( + "data-index=\"0\" data-count=\"1\" data-even=\"false\" data-odd=\"true\"")); + assertTrue( + html, + html.contains( + "data-index=\"1\" data-count=\"2\" data-even=\"true\" data-odd=\"false\"")); + } + + @Test + public void redirectsNeedNoTemplatesAndVaryByHtmxRequestKind() throws Exception { + setup(); + HttpServer.Handler handler = + controller( + "package sample; import com.codename1.backend.annotations.*; @Controller" + + " public class Pages { @GetMapping(\"/old\") public String redirect()" + + " {return \"redirect:/new\";} }"); + for (boolean hx : Arrays.asList(false, true)) { + Object response = handler.handle(request("GET", "/old", null, hx)); + assertEquals(hx ? 200 : 303, field(response, "status")); + Map headers = (Map) field(response, "extraHeaders"); + assertEquals("/new", headers.get(hx ? "HX-Redirect" : "Location")); + assertEquals("HX-Request, HX-History-Restore-Request", headers.get("Vary")); + } + Object history = handler.handle(request("GET", "/old", null, true, true)); + assertEquals(303, field(history, "status")); + assertEquals( + "HX-Request, HX-History-Restore-Request", + ((Map) field(history, "extraHeaders")).get("Vary")); + } + + @Test + public void staticAssetRootCannotBeASymlink() throws Exception { + setup(); + File root = new File(project, "src/main/resources/static"); + assertTrue(root.getParentFile().mkdirs()); + File outside = tmp.newFolder(); + Files.write( + new File(outside, "secret.txt").toPath(), + "private".getBytes(StandardCharsets.UTF_8)); + try { + Files.createSymbolicLink(root.toPath(), outside.toPath()); + } catch (UnsupportedOperationException | java.nio.file.FileSystemException unavailable) { + org.junit.Assume.assumeNoException(unavailable); + } + try { + MvcAssets.sources(project); + fail("A symlinked public root must not embed files outside the project"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("symlink")); + } + } + + @Test + public void colonsInPathsQueriesAndFragmentsAreNotSchemes() throws Exception { + setup(); + template("a", "event"); + compile(); + assertTrue(render("a", new Model()).contains("href=\"/events/12:30\"")); + for (String url : + Arrays.asList("/objects/urn:123", "relative/path:part", "?time=12:30", "#urn:123")) + Html.safeUrl(url); + for (String url : + Arrays.asList("javascript:alert(1)", "data:text/html,x", "ftp://example.test/")) { + try { + Html.safeUrl(url); + fail(url); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("Unsafe URL")); + } + } + } + + @Test + public void setterBindingPreservesJavaBeansAcronyms() throws Exception { + setup(); + fixtureSources( + Collections.singletonMap( + "sample.Form", + "package sample; public class Form {private String URL, name; public void" + + " setURL(String value){URL=value;} public String getURL(){return" + + " URL;}public void setName(String value){name=value;} public String" + + " getName(){return name;} }")); + template( + "a", + "
    "); + HttpServer.Handler handler = + controller( + "package sample; import com.codename1.backend.annotations.*; @Controller" + + " public class Pages { @PostMapping(\"/save\") public String" + + " save(@ModelAttribute(\"form\") Form form) {return \"a\";} }"); + String html = + body( + handler.handle( + request( + "POST", + "/save", + "URL=https%3A%2F%2Fexample.test&name=Alice", + false))); + assertTrue(html, html.contains("value=\"https://example.test\"")); + assertTrue(html, html.contains("value=\"Alice\"")); + } + + @Test + public void propertiesResolveInheritedInterfacesAndDefaultGetters() throws Exception { + setup(); + Map fixtures = new LinkedHashMap(); + fixtures.put( + "sample.Named", + "package sample; public interface Named {default String getName(){return" + + " \"inherited\";}} "); + fixtures.put("sample.Left", "package sample; public interface Left extends Named {} "); + fixtures.put("sample.Right", "package sample; public interface Right extends Named {} "); + fixtures.put( + "sample.Child", "package sample; public interface Child extends Left,Right {} "); + fixtures.put( + "sample.DefaultProduct", + "package sample; public class DefaultProduct implements Child {} "); + fixtures.put( + "sample.BaseProduct", + "package sample; public class BaseProduct {public String getName(){return \"class" + + " getter\";}} "); + fixtures.put( + "sample.OverrideProduct", + "package sample; public class OverrideProduct extends BaseProduct implements Child" + + " {} "); + fixtureSources(fixtures); + template( + "a", + ""); + compile(); + Object product = loader.loadClass("sample.DefaultProduct").newInstance(); + String html = + render( + "a", + new Model() + .addAttribute("typed", product) + .addAttribute("concrete", product) + .addAttribute( + "overridden", + loader.loadClass("sample.OverrideProduct").newInstance())); + assertTrue(html, html.contains("inheritedinheritedclass getter")); + } + + @Test + public void svgLinksRejectUnsafeSchemes() throws Exception { + setup(); + template( + "svg", + "Open"); + compile(); + for (String url : + Arrays.asList( + "javascript:alert(1)", + "JaVaScRiPt:alert(1)", + "data:text/html,test", + "java\nscript:alert(1)")) { + try { + render("svg", new Model().addAttribute("url", url)); + fail(url); + } catch (IllegalArgumentException expected) { + } + } + assertTrue( + render("svg", new Model().addAttribute("url", "/products#list")) + .contains("xlink:href=\"/products#list\"")); + } + + @Test + public void templateRootCannotBeASymlink() throws Exception { + setup(); + File root = new File(project, "src/main/resources/templates"); + assertTrue(root.getParentFile().mkdirs()); + File outside = tmp.newFolder(); + Files.write( + new File(outside, "secret.html").toPath(), + "private".getBytes(StandardCharsets.UTF_8)); + try { + Files.createSymbolicLink(root.toPath(), outside.toPath()); + } catch (UnsupportedOperationException | java.nio.file.FileSystemException unavailable) { + org.junit.Assume.assumeNoException(unavailable); + } + try { + new MvcTemplates(context).sources(); + fail("Template root symlink accepted"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("symlink")); + } + } + + @Test + public void urlParametersPrecedeFragments() throws Exception { + setup(); + template( + "urls", + "abcde"); + compile(); + org.jsoup.select.Elements links = + org.jsoup.Jsoup.parse(render("urls", new Model().addAttribute("page", 2))) + .select("a"); + assertEquals("/products?page=2#list", links.get(0).attr("href")); + assertEquals("/products?sort=name&page=2#list", links.get(1).attr("href")); + assertEquals("/products/2?q=x%26y#a%20b", links.get(2).attr("href")); + assertEquals("/products?page=2#list?ignored", links.get(3).attr("href")); + assertEquals("/products#list", links.get(4).attr("href")); + } + + @Test + public void mixedCaseAssetExtensionsHaveBrowserMimeTypes() throws Exception { + setup(); + File root = new File(project, "src/main/resources/static"); + assertTrue(root.mkdirs()); + String[] names = {"App.JS", "site.CsS", "icon.SVG"}; + String[] types = { + "text/javascript; charset=utf-8", "text/css; charset=utf-8", "image/svg+xml" + }; + for (String name : names) + Files.write(new File(root, name).toPath(), "asset".getBytes(StandardCharsets.UTF_8)); + JavaSourceCompiler.compile(MvcAssets.sources(project), classes, classpath()); + loader = + new URLClassLoader( + new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); + HttpServer.Handler assets = + (HttpServer.Handler) + loader.loadClass("com.codename1.generated.mvc.Assets").newInstance(); + for (int i = 0; i < names.length; i++) { + Object response = assets.handle(request("GET", "/static/" + names[i], null, false)); + assertEquals(types[i], field(response, "contentType")); + assertEquals( + "nosniff", + ((Map) field(response, "extraHeaders")).get("X-Content-Type-Options")); + assertEquals("asset", body(response)); + } + assertNull(assets.handle(request("GET", "/static/app.js", null, false))); + } + + @Test + public void submitMethodOverridesIncludeCsrfTokens() throws Exception { + setup(); + String declaration = ""; + template( + "forms", + declaration + + "
    Save
    Save
    Save"); + template( + "control", + declaration + + ""); + compile(); + Model model = + new Model() + .addAttribute("method", "post") + .addAttribute( + "_csrf", + new com.codename1.backend.security.CsrfToken() { + public String getToken() { + return "token<&"; + } + + public String getHeaderName() { + return "X-CSRF-TOKEN"; + } + + public String getParameterName() { + return "_csrf"; + } + }); + org.jsoup.nodes.Document html = org.jsoup.Jsoup.parse(render("forms", model)); + for (String id : Arrays.asList("static", "input", "dynamic", "fragment")) { + org.jsoup.select.Elements tokens = html.select("form#" + id + " input[name=_csrf]"); + assertEquals(id, 1, tokens.size()); + assertEquals("token<&", tokens.first().val()); + } + assertEquals("token<&", html.select("input[name=_csrf][form=external]").val()); + assertTrue(html.select("form#get input[name=_csrf]").isEmpty()); + model.addAttribute("method", null); + html = org.jsoup.Jsoup.parse(render("forms", model)); + assertTrue( + html.select("form#dynamic input[name=_csrf], form#fragment input[name=_csrf]") + .isEmpty()); + assertFalse( + render("forms", new Model().addAttribute("method", "post")) + .contains("name=\"_csrf\"")); + } + + @Test + public void implicitOptionValuesFollowRenderedText() throws Exception { + setup(); + template( + "options", + DECL + + "" + + "
    "); + compile(); + Model model = + new Model() + .addAttribute("product", product("A & B", 0)) + .addAttribute("label", " \tA &\n B\r "); + org.jsoup.select.Elements options = + org.jsoup.Jsoup.parse(render("options", model)).select("option"); + assertFalse(options.get(0).hasAttr("selected")); + assertTrue(options.get(1).hasAttr("selected")); + assertEquals("A & B", options.get(1).text()); + assertFalse(options.get(2).hasAttr("selected")); + BindingResult errors = new BindingResult(); + errors.submitted("name", "explicit"); + model.addAttribute("BindingResult.product", errors); + options = org.jsoup.Jsoup.parse(render("options", model)).select("option"); + assertFalse(options.get(1).hasAttr("selected")); + assertTrue(options.get(2).hasAttr("selected")); + } + + @Test + public void omittedPrimitiveFieldsKeepDefaultsAndMarkersStillClearCheckboxes() + throws Exception { + setup(); + fixtureSources( + Collections.singletonMap( + "sample.DefaultForm", + "package sample; public class DefaultForm { public byte b=1; public short" + + " s=2; public int n=3; public long l=4; public float f=5; public" + + " double d=6; public char c='Q'; public boolean active=true; private" + + " int count=7; public int calls; public void setCount(int" + + " v){count=v;calls++;} public String summary(){return" + + " b+\"|\"+s+\"|\"+n+\"|\"+l+\"|\"+f+\"|\"+d+\"|\"+c+\"|\"+active+\"|\"+count+\"|\"+calls;}" + + " }")); + HttpServer.Handler handler = + controller( + "package sample; import com.codename1.backend.annotations.*; import" + + " com.codename1.backend.mvc.*; @Controller public class Pages {" + + " @PostMapping(\"/bind\") @ResponseBody public String" + + " bind(@ModelAttribute(\"form\") DefaultForm form, BindingResult" + + " errors) { return" + + " form.summary()+\"|\"+errors.hasErrors()+\"|\"+errors.fieldValue(\"n\"," + + " form.n); } @PostMapping(\"/strict\") @ResponseBody public String" + + " strict(@ModelAttribute(\"form\") DefaultForm form) {return" + + " form.summary();} }"); + String defaults = "1|2|3|4|5.0|6.0|Q|true|7|0"; + assertEquals( + defaults + "|false|3", body(handler.handle(request("POST", "/bind", "", false)))); + Object strict = handler.handle(request("POST", "/strict", "", false)); + assertEquals(200, field(strict, "status")); + assertEquals(defaults, body(strict)); + assertEquals( + "1|2|3|4|5.0|6.0|Q|false|7|0|false|3", + body(handler.handle(request("POST", "/bind", "_active=on", false)))); + assertEquals( + "1|2|8|4|5.0|6.0|Z|true|9|1|false|8", + body(handler.handle(request("POST", "/bind", "n=8&c=Z&count=9", false)))); + for (String invalid : Arrays.asList("n=", "n=bad", "c=", "c=long")) { + assertEquals( + 400, + field(handler.handle(request("POST", "/strict", invalid, false)), "status")); + assertEquals( + "true", + body(handler.handle(request("POST", "/bind", invalid, false))) + .split("\\|", -1)[10]); + } + } + + @Test + public void rawTextElementsRejectDynamicFragmentInsertionAndErrors() throws Exception { + for (String tag : Arrays.asList("script", "style")) { + for (String directive : Arrays.asList("insert", "errors")) { + setup(); + template( + "parts", + ""); + template( + "unsafe", + DECL + + "
    <" + + tag + + " th:" + + directive + + "=\"" + + (directive.equals("insert") ? "~{parts :: code}" : "*{name}") + + "\">
    "); + try { + new MvcTemplates(context).sources(); + fail(tag + " th:" + directive); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), expected.getMessage().contains("script/style")); + } + } + } + setup(); + template("safe", ""); + compile(); + assertTrue(render("safe", new Model()).contains("p {color: red}")); + } + + @Test + public void omittedReferenceFieldsPreserveDefaultsButExplicitEmptyValuesBind() + throws Exception { + setup(); + fixtureSources( + Collections.singletonMap( + "sample.BoxedForm", + "package sample; public class BoxedForm { public Byte b=1; public Short" + + " s=2; public Integer n=3; public Long l=4L; public Float f=5F;" + + " public Double d=6D; public Character c='Q'; public Boolean" + + " active=true; public String name=\"default\"; private Integer" + + " count=7; public int calls; public void setCount(Integer" + + " v){count=v;calls++;} public String summary(){return" + + " b+\"|\"+s+\"|\"+n+\"|\"+l+\"|\"+f+\"|\"+d+\"|\"+c+\"|\"+active+\"|\"+name+\"|\"+count+\"|\"+calls;}" + + " }")); + HttpServer.Handler handler = + controller( + "package sample; import com.codename1.backend.annotations.*; import" + + " com.codename1.backend.mvc.*; @Controller public class Pages {" + + " @PostMapping(\"/bind\") @ResponseBody public String" + + " bind(@ModelAttribute(\"form\") BoxedForm form, BindingResult" + + " errors) { return" + + " form.summary()+\"|\"+errors.hasErrors()+\"|\"+errors.fieldValue(\"n\",form.n);" + + " } }"); + assertEquals( + "1|2|3|4|5.0|6.0|Q|true|default|7|0|false|3", + body(handler.handle(request("POST", "/bind", "", false)))); + assertEquals( + "1|2|3|4|5.0|6.0|Q|false|default|7|0|false|3", + body(handler.handle(request("POST", "/bind", "_active=on", false)))); + assertEquals( + "1|2|null|4|5.0|6.0|null|true||null|1|false|", + body(handler.handle(request("POST", "/bind", "n=&c=&name=&count=", false)))); + } + + @Test + public void htmxDescendantsAndAssociatedControlsCarryCsrf() throws Exception { + setup(); + template( + "hx", + "
    Save
    Save
    Save
    Search"); + template("hxparts", ""); + compile(); + Model model = + new Model() + .addAttribute("target", "/save") + .addAttribute( + "_csrf", + new com.codename1.backend.security.DefaultCsrfToken( + "X-CSRF-TOKEN", "_csrf", "token<&")); + org.jsoup.nodes.Document html = org.jsoup.Jsoup.parse(render("hx", model)); + for (String id : Arrays.asList("post", "put", "patch", "delete")) { + assertEquals(id, "token<&", html.select("form#" + id + " input[name=_csrf]").val()); + } + assertEquals("token<&", html.select("input[name=_csrf][form=external]").val()); + assertTrue(html.select("form#get input[name=_csrf]").isEmpty()); + model.addAttribute("target", null); + assertTrue( + org.jsoup.Jsoup.parse(render("hx", model)) + .select("form#delete input[name=_csrf]") + .isEmpty()); + } + + @Test + public void urlExpressionsPreserveRepeatedQueryNames() throws Exception { + setup(); + template( + "repeat", + "SearchProduct"); + compile(); + org.jsoup.select.Elements links = + org.jsoup.Jsoup.parse(render("repeat", new Model())).select("a"); + assertEquals("/search?tag=a%20b&tag=c%26d&page=2#list", links.get(0).attr("href")); + assertEquals("/products/7/7?tag=a&tag=b#details", links.get(1).attr("href")); + template("repeat", "Ambiguous"); + try { + new MvcTemplates(context).sources(); + fail("Ambiguous path parameter accepted"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("one value")); + } + } + + @Test + public void dynamicInputTypesBindCheckboxRadioAndText() throws Exception { + setup(); + template( + "type", + DECL + + ""); + compile(); + Object p = product("test", 1); + p.getClass().getField("active").setBoolean(p, true); + Model model = new Model().addAttribute("product", p); + for (String kind : Arrays.asList("checkbox", "RADIO", "text")) { + model.addAttribute("kind", kind); + org.jsoup.nodes.Document html = org.jsoup.Jsoup.parse(render("type", model)); + assertEquals( + !kind.equals("text"), + html.select("input[name=active]").first().hasAttr("checked")); + assertEquals( + kind.equals("checkbox") ? 1 : 0, html.select("input[name=_active]").size()); + assertEquals("true", html.select("input[name=active]").val()); + } + p.getClass().getField("active").setBoolean(p, false); + model.addAttribute("kind", "checkbox"); + org.jsoup.nodes.Document html = org.jsoup.Jsoup.parse(render("type", model)); + assertFalse(html.select("input[name=active]").first().hasAttr("checked")); + assertEquals("true", html.select("input[name=active]").val()); + } + + @Test + public void hugeAssetsAreRejectedBeforeAllocation() throws Exception { + setup(); + File file = new File(project, "src/main/resources/static/huge.bin"); + assertTrue(file.getParentFile().mkdirs()); + try (RandomAccessFile sparse = new RandomAccessFile(file, "rw")) { + sparse.setLength((long) Integer.MAX_VALUE + 1); + } + try { + MvcAssets.sources(project); + fail("Oversize asset accepted"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("2 MiB")); + } catch (OutOfMemoryError allocation) { + fail("Asset size was not checked before allocation"); + } + } + + @Test + public void truthConversionMatchesSupportedThymeleafScalars() throws Exception { + setup(); + template( + "truth", + "" + + "ifunless" + + "" + + ""); + compile(); + for (String value : Arrays.asList("false", "NO", " Off ", null)) { + org.jsoup.nodes.Document html = + org.jsoup.Jsoup.parse( + render("truth", new Model().addAttribute("value", value))); + assertTrue(html.select("b").isEmpty()); + assertEquals("unless", html.select("i").text()); + assertFalse(html.select("input").first().hasAttr("disabled")); + assertEquals("false", html.select("em").text()); + assertEquals("true", html.select("strong").text()); + } + for (String value : Arrays.asList("true", "yes", "on", "", "0")) + assertTrue(Html.truth(value)); + for (Object value : Arrays.asList(false, 0, 0L, 0.0, '\0')) + assertFalse(Html.truth(value)); + for (Object value : Arrays.asList(true, 1, -1L, 0.5, 'x')) + assertTrue(Html.truth(value)); + } + + @Test + public void objectDataUrlsRejectExecutableSchemes() throws Exception { + setup(); + template( + "object", + ""); + compile(); + for (String url : + Arrays.asList("data:text/html,", "JaVaScRiPt:alert(1)")) { + try { + render("object", new Model().addAttribute("url", url)); + fail("Accepted executable object data: " + url); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("URL")); + } + } + for (String url : + Arrays.asList("/static/manual.pdf", "https://example.com/manual.pdf?a=1&b=2")) { + assertEquals( + url, + org.jsoup.Jsoup.parse(render("object", new Model().addAttribute("url", url))) + .select("object") + .attr("data")); + } + } + + @Test + public void genericPropertiesRetainOwnerAndInheritedTypeArguments() throws Exception { + setup(); + Map fixtures = new LinkedHashMap(); + fixtures.put( + "sample.Box", + "package sample; public class Box { public T value; public T getValue(){return" + + " value;} public java.util.List getItems(){return" + + " java.util.Collections.singletonList(value);} }"); + fixtures.put("sample.Middle", "package sample; public class Middle extends Box {}"); + fixtures.put( + "sample.ProductBox", + "package sample; public class ProductBox extends Middle { public" + + " ProductBox(){value=new Product();value.name=\"generic\";} }"); + fixtures.put("sample.Named", "package sample; public interface Named { T getValue(); }"); + fixtures.put( + "sample.Child", "package sample; public interface Child extends Named {}"); + fixtures.put( + "sample.NamedProduct", + "package sample; public class NamedProduct extends ProductBox implements" + + " Child {}"); + fixtures.put( + "sample.Fields", + "package sample; public class Fields { public T value; public T[] values; }"); + fixtureSources(fixtures); + template( + "generic", + ""); + compile(); + Object box = loader.loadClass("sample.ProductBox").newInstance(); + Object fields = loader.loadClass("sample.Fields").newInstance(); + Object value = box.getClass().getField("value").get(box); + fields.getClass().getField("value").set(fields, value); + Object array = Array.newInstance(value.getClass(), 1); + Array.set(array, 0, value); + fields.getClass().getField("values").set(fields, array); + String html = + render( + "generic", + new Model() + .addAttribute("direct", box) + .addAttribute("inherited", box) + .addAttribute( + "named", + loader.loadClass("sample.NamedProduct").newInstance()) + .addAttribute("fields", fields)); + assertTrue( + html, + html.contains( + "genericgenericgenericgenericgenericgeneric")); + } + + @Test + public void inheritedGenericPropertiesSupportNestedAndMultipleArguments() throws Exception { + setup(); + Map fixtures = new LinkedHashMap(); + fixtures.put( + "sample.Pair", + "package sample; public class Pair { public B field; public B" + + " getValue(){return field;} }"); + fixtures.put( + "sample.Nested", + "package sample; public class Nested extends Pair> {}"); + fixtureSources(fixtures); + template( + "nested", + ""); + compile(); + Object nested = loader.loadClass("sample.Nested").newInstance(); + nested.getClass() + .getField("field") + .set(nested, Collections.singletonList(product("nested", 1))); + Object pair = loader.loadClass("sample.Pair").newInstance(); + pair.getClass().getField("field").set(pair, Collections.singletonList(product("pair", 1))); + String html = + render( + "nested", + new Model().addAttribute("nested", nested).addAttribute("pair", pair)); + assertTrue(html, html.contains("nestednestedpair")); + } + + @Test + public void incrementalBuildRemovesObsoleteAssetClassesFromPackagedOutput() throws Exception { + setup(); + template("home", "

    Home

    "); + File assets = new File(project, "src/main/resources/static"); + assertTrue(assets.mkdirs()); + Files.write(new File(assets, "a.txt").toPath(), "keep".getBytes(StandardCharsets.UTF_8)); + File removed = new File(assets, "z.txt"); + Files.write(removed.toPath(), "removed-private-content".getBytes(StandardCharsets.UTF_8)); + String pages = + "package sample; import com.codename1.backend.annotations.*; @Controller public" + + " class Pages { @GetMapping(\"/\") public String home(){return \"home\";} }"; + controller(pages); + File generated = new File(classes, "com/codename1/generated/mvc"); + assertTrue(new File(generated, "Asset1.class").isFile()); + fixtureSources( + Collections.singletonMap( + "com.codename1.generated.mvc.Asset99", + "package com.codename1.generated.mvc; public class Asset99 {}")); + assertTrue(removed.delete()); + controller(pages); + assertFalse( + "Removed asset bytecode survives incremental compilation", + new File(generated, "Asset1.class").exists()); + assertTrue("User classes must be preserved", new File(generated, "Asset99.class").isFile()); + assertTrue(new File(generated, "Asset0.class").isFile()); + File jar = new File(project, "application.jar"); + try (java.util.jar.JarOutputStream out = + new java.util.jar.JarOutputStream(new FileOutputStream(jar)); + java.util.stream.Stream files = Files.walk(classes.toPath())) { + for (java.nio.file.Path file : + (Iterable) files.filter(Files::isRegularFile)::iterator) { + out.putNextEntry( + new java.util.jar.JarEntry( + classes.toPath() + .relativize(file) + .toString() + .replace(File.separatorChar, '/'))); + Files.copy(file, out); + out.closeEntry(); + } + } + try (java.util.jar.JarFile packaged = new java.util.jar.JarFile(jar)) { + assertNull(packaged.getEntry("com/codename1/generated/mvc/Asset1.class")); + assertNotNull(packaged.getEntry("com/codename1/generated/mvc/Asset0.class")); + } + assertTrue(new File(assets, "a.txt").delete()); + controller(pages); + assertFalse(new File(generated, "Asset0.class").exists()); + } + + @Test + public void checkboxMarkersMirrorDisabledAndFormAttributes() throws Exception { + setup(); + template( + "markers", + DECL + + "
    " + + "
    "); + compile(); + Model model = + new Model() + .addAttribute("product", product("item", 1)) + .addAttribute("disabled", true) + .addAttribute("owner", "other") + .addAttribute("kind", "checkbox"); + for (Object disabled : Arrays.asList(true, false, null)) { + model.addAttribute("disabled", disabled); + org.jsoup.nodes.Document html = org.jsoup.Jsoup.parse(render("markers", model)); + for (String id : Arrays.asList("static", "dynamic")) { + org.jsoup.nodes.Element checkbox = html.getElementById(id); + org.jsoup.nodes.Element marker = checkbox.previousElementSibling(); + assertEquals("_active", marker.attr("name")); + assertEquals(checkbox.hasAttr("disabled"), marker.hasAttr("disabled")); + assertEquals(checkbox.attr("form"), marker.attr("form")); + } + } + model.addAttribute("owner", null); + org.jsoup.nodes.Element checkbox = + org.jsoup.Jsoup.parse(render("markers", model)).getElementById("dynamic"); + assertFalse(checkbox.hasAttr("form")); + assertFalse(checkbox.previousElementSibling().hasAttr("form")); + model.addAttribute("kind", "radio"); + assertEquals( + 1, + org.jsoup.Jsoup.parse(render("markers", model)) + .select("input[name=_active]") + .size()); + } + + @Test + public void dataHtmxAliasesRejectExecutableAttributes() throws Exception { + setup(); + for (String attribute : + Arrays.asList( + "data-hx-on:click", + "DATA-HX-ON::before-request", + "data-hx-vals", + "Data-Hx-Headers")) { + template( + "alias", + ""); + try { + new MvcTemplates(context).sources(); + fail("Accepted executable alias " + attribute); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("Unsupported dynamic attribute")); + } + } + } + + @Test + public void dataHtmxAliasesUseUrlValidationAndCsrfHandling() throws Exception { + setup(); + template( + "alias", + "
    DeleteGet"); + compile(); + Model model = + new Model() + .addAttribute("url", "/save") + .addAttribute( + "_csrf", + new com.codename1.backend.security.CsrfToken() { + public String getToken() { + return "token"; + } + + public String getHeaderName() { + return "X-CSRF-TOKEN"; + } + + public String getParameterName() { + return "_csrf"; + } + }); + org.jsoup.nodes.Document html = org.jsoup.Jsoup.parse(render("alias", model)); + assertEquals(1, html.select("form#static input[name=_csrf]").size()); + assertEquals(1, html.select("form#dynamic input[name=_csrf]").size()); + assertEquals(1, html.select("input[name=_csrf][form=static]").size()); + model.addAttribute("url", null); + html = org.jsoup.Jsoup.parse(render("alias", model)); + assertTrue( + html.select("form#dynamic input[name=_csrf], input[name=_csrf][form=static]") + .isEmpty()); + assertFalse(html.getElementById("dynamic").hasAttr("data-hx-post")); + assertFalse(html.getElementById("dynamic").hasAttr("hx-post")); + for (String url : Arrays.asList("javascript:alert(1)", "data:text/html,bad")) { + model.addAttribute("url", url); + try { + render("alias", model); + fail("Unsafe alias URL " + url); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("URL")); + } + } + } + + @Test + public void nestedModelDeclarationsAndPropertiesUseJavaSourceNames() throws Exception { + setup(); + fixtureSources( + Collections.singletonMap( + "sample.Forms", + "package sample; public class Forms { public static class Edit { public" + + " String name=\"nested\"; public Edit getSelf(){return this;} public" + + " java.util.List getItems(){return" + + " java.util.Collections.singletonList(this);} } }")); + template( + "nested", + ""); + compile(); + Object form = loader.loadClass("sample.Forms$Edit").newInstance(); + String html = + render( + "nested", + new Model() + .addAttribute("form", form) + .addAttribute("forms", Collections.singletonList(form))); + assertTrue(html, html.contains("nestednestednestednested")); + } + + @Test + public void nestedClasspathModelsPreserveActualDollarNames() throws Exception { + setup(); + Map fixtures = new LinkedHashMap(); + fixtures.put( + "sample.Forms", + "package sample; public class Forms { public static class Edit { public String" + + " name=\"nested\"; } }"); + fixtures.put( + "sample.Dollar$Model", + "package sample; public class Dollar$Model { public String name=\"dollar\"; }"); + fixtureSources(fixtures); + context = + new ProcessorContext( + classes, + tmp.newFolder(), + Collections.emptyMap(), + new SystemStreamLog(), + project, + new Properties(), + null, + Collections.emptyList(), + "UTF-8", + Arrays.asList(classpath().get(0).getPath(), classes.getPath())); + template( + "classpath", + ""); + compile(); + Object form = loader.loadClass("sample.Forms$Edit").newInstance(); + String html = + render( + "classpath", + new Model() + .addAttribute("form", form) + .addAttribute("binary", form) + .addAttribute( + "dollar", + loader.loadClass("sample.Dollar$Model").newInstance())); + assertTrue(html, html.contains("nestednesteddollar")); + } + + @Test + public void booleanCheckboxesUsePresenceWithCustomValuesAndRetainRadioSemantics() + throws Exception { + setup(); + template( + "checks", + DECL + + "
    "); + HttpServer.Handler handler = + controller( + "package sample; import com.codename1.backend.annotations.*; import" + + " com.codename1.backend.mvc.*; @Controller public class Pages {" + + " @GetMapping(\"/checks\") public String checks(){return \"checks\";}" + + " @PostMapping(\"/bind\") @ResponseBody public String" + + " bind(@ModelAttribute(\"product\") Product p, BindingResult errors)" + + " { return" + + " p.active+\"|\"+errors.hasErrors()+\"|\"+errors.fieldValue(\"active\"," + + " p.active); } }"); + Object product = product("item", 1); + product.getClass().getField("active").setBoolean(product, true); + Model model = new Model().addAttribute("product", product).addAttribute("kind", "checkbox"); + for (String candidate : Arrays.asList("yes", "1", "false", "", "custom")) { + model.addAttribute("candidate", candidate); + org.jsoup.nodes.Document html = org.jsoup.Jsoup.parse(render("checks", model)); + assertTrue(candidate, html.getElementById("check").hasAttr("checked")); + assertEquals(candidate, html.getElementById("check").val()); + assertFalse(html.getElementById("radio").hasAttr("checked")); + String submitted = + "_active=on&active=" + java.net.URLEncoder.encode(candidate, "UTF-8"); + assertEquals( + "true|false|true", + body(handler.handle(request("POST", "/bind", submitted, false)))); + } + assertEquals( + "false|false|false", + body(handler.handle(request("POST", "/bind", "_active=on", false)))); + assertEquals( + "false|false|false", + body(handler.handle(request("POST", "/bind", "active=false", false)))); + assertTrue( + body(handler.handle(request("POST", "/bind", "active=invalid", false))) + .contains("|true|")); + product.getClass().getField("active").setBoolean(product, false); + model.addAttribute("candidate", "false"); + org.jsoup.nodes.Document html = org.jsoup.Jsoup.parse(render("checks", model)); + assertFalse(html.getElementById("check").hasAttr("checked")); + assertTrue(html.getElementById("radio").hasAttr("checked")); + } + + @Test + public void incrementalBuildRemovesViewsWhenLastViewRouteIsRemoved() throws Exception { + setup(); + template("private", "

    deleted private template

    "); + controller( + "package sample; import com.codename1.backend.annotations.*; @Controller public" + + " class Pages { @GetMapping(\"/\") public String page(){return \"private\";}" + + " }"); + File views = new File(classes, "com/codename1/generated/mvc/Views.class"); + assertTrue(views.isFile()); + assertTrue(new File(project, "src/main/resources/templates/private.html").delete()); + controller( + "package sample; import com.codename1.backend.annotations.*; import" + + " com.codename1.backend.HttpServer; @Controller public class Pages {" + + " @GetMapping(\"/\") public HttpServer.Response page(){return" + + " HttpServer.Response.text(200,\"plain\");} }"); + assertFalse("Deleted template bytecode survives rebuild", views.exists()); + fixtureSources( + Collections.singletonMap( + "com.codename1.generated.mvc.Views", + "package com.codename1.generated.mvc; public class Views {}")); + MvcAssets.removeObsoleteClasses(classes, Collections.emptySet()); + assertTrue("User-written Views must survive", views.isFile()); + } + + @Test + public void inheritedGenericScalarFormPropertiesBindResolvedTypes() throws Exception { + setup(); + Map fixtures = new LinkedHashMap(); + fixtures.put( + "sample.Base", + "package sample; public class Base { private T value; public T field; public" + + " void setValue(T value){this.value=value;} public T getValue(){return" + + " value;} }"); + fixtures.put("sample.Middle", "package sample; public class Middle extends Base {}"); + fixtures.put( + "sample.IntForm", + "package sample; public class IntForm extends Middle { public" + + " IntForm(){field=7;setValue(8);} }"); + fixtureSources(fixtures); + HttpServer.Handler handler = + controller( + "package sample; import com.codename1.backend.annotations.*; import" + + " com.codename1.backend.mvc.*; @Controller public class Pages {" + + " @PostMapping(\"/bind\") @ResponseBody public String" + + " bind(@ModelAttribute(\"form\") IntForm f, BindingResult" + + " errors){return" + + " f.getValue()+\"|\"+f.field+\"|\"+errors.hasErrors();} }"); + assertEquals( + "12|34|false", + body(handler.handle(request("POST", "/bind", "value=12&field=34", false)))); + assertEquals("8|7|false", body(handler.handle(request("POST", "/bind", "", false)))); + assertEquals( + "null|null|false", + body(handler.handle(request("POST", "/bind", "value=&field=", false)))); + assertEquals( + "8|7|true", + body(handler.handle(request("POST", "/bind", "value=bad&field=bad", false)))); + } + + @Test + public void dynamicHtmxExpressionAttributesAreRejectedIncludingAliases() throws Exception { + setup(); + for (String name : Arrays.asList("hx-vars", "hx-request", "hx-trigger")) { + for (String prefix : Arrays.asList("", "DATA-")) { + template( + "executable", + ""); + try { + new MvcTemplates(context).sources(); + fail("Accepted executable attribute " + prefix + name); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("Unsupported dynamic attribute")); + } + } + } + template( + "executable", + ""); + compile(); + assertTrue(render("executable", new Model()).contains("click[ctrlKey]")); + } + + @Test + public void numericConditionalsRemainNumericInComparisonsAndArithmetic() throws Exception { + setup(); + template( + "conditional", + " 0}\"> 0}\">"); + compile(); + Model model = + new Model() + .addAttribute("whole", 9007199254740993L) + .addAttribute("count", 2) + .addAttribute("flag", true); + String html = render("conditional", model); + assertTrue( + html, + html.contains("true2.0truetrue2")); + model.addAttribute("flag", false); + html = render("conditional", model); + assertTrue( + html, + html.contains("true3.5falsetrue3")); + } + + @Test + public void primitiveIsGettersTakePrecedenceRegardlessOfDeclarationOrder() throws Exception { + setup(); + Map fixtures = new LinkedHashMap(); + fixtures.put( + "sample.GetFirst", + "package sample; public class GetFirst { public Boolean getActive(){return false;}" + + " public boolean isActive(){return true;} }"); + fixtures.put( + "sample.IsFirst", + "package sample; public class IsFirst { public boolean isActive(){return true;}" + + " public Boolean getActive(){return false;} }"); + fixtures.put( + "sample.Inherited", + "package sample; public class Inherited extends IsFirst { public Boolean" + + " getActive(){return false;} }"); + fixtures.put( + "sample.BoxedIs", + "package sample; public class BoxedIs { public Boolean isActive(){return false;}" + + " public boolean getActive(){return true;} }"); + fixtureSources(fixtures); + template( + "beans", + ""); + compile(); + String html = + render( + "beans", + new Model() + .addAttribute( + "a", loader.loadClass("sample.GetFirst").newInstance()) + .addAttribute("b", loader.loadClass("sample.IsFirst").newInstance()) + .addAttribute( + "c", loader.loadClass("sample.Inherited").newInstance()) + .addAttribute( + "d", loader.loadClass("sample.BoxedIs").newInstance())); + assertTrue(html, html.contains("truetruetruetrue")); + } + + @Test + public void scriptSourcesAndBaseUrlsMustBeStatic() throws Exception { + setup(); + for (String element : Arrays.asList("script", "base")) { + String attr = element.equals("script") ? "src" : "href"; + for (String directive : + Arrays.asList( + "th:" + attr + "=\"${url}\"", + "th:attr=\"" + attr.toUpperCase(java.util.Locale.ROOT) + "=${url}\"")) { + template( + "unsafe", + "<" + + element + + " " + + directive + + ">"); + try { + new MvcTemplates(context).sources(); + fail("Accepted dynamic " + element + " " + attr); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("Dynamic " + element)); + } + } + } + template("unsafe", ""); + compile(); + assertTrue(render("unsafe", new Model()).contains("src=\"/static/app.js\"")); + } + + @Test + public void embeddedAssetMimeTypesMatchStaticFiles() throws Exception { + setup(); + File root = new File(project, "src/main/resources/static"); + assertTrue(root.mkdirs()); + String[] extensions = { + "mjs", "MJS", "wasm", "htm", "json", "gif", "webp", "woff", "ttf", "pdf", "md", "xml", + "mp4", "zip", "unknown" + }; + for (int i = 0; i < extensions.length; i++) + Files.write( + new File(root, "app" + i + "." + extensions[i]).toPath(), new byte[] {1, 2}); + JavaSourceCompiler.compile(MvcAssets.sources(project), classes, classpath()); + loader = + new URLClassLoader( + new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); + HttpServer.Handler handler = + (HttpServer.Handler) + loader.loadClass("com.codename1.generated.mvc.Assets").newInstance(); + Method mime = + com.codename1.backend.StaticFiles.class.getDeclaredMethod( + "contentType", String.class); + mime.setAccessible(true); + for (int i = 0; i < extensions.length; i++) { + String path = "/static/app" + i + "." + extensions[i]; + Object response = handler.handle(request("GET", path, null, false)); + assertEquals(path, mime.invoke(null, path), field(response, "contentType")); + assertEquals( + "nosniff", + ((Map) field(response, "extraHeaders")).get("X-Content-Type-Options")); + } + } + + @Test + public void responseOnlyMvcControllersWireEmbeddedAssetsWithoutViews() throws Exception { + setup(); + File asset = new File(project, "src/main/resources/static/app.mjs"); + assertTrue(asset.getParentFile().mkdirs()); + Files.write(asset.toPath(), "export const ok=true;".getBytes(StandardCharsets.UTF_8)); + controller( + "package sample; import com.codename1.backend.annotations.*; import" + + " com.codename1.backend.HttpServer; @Controller public class Pages {" + + " @GetMapping(\"/\") public HttpServer.Response page(){return" + + " HttpServer.Response.text(200,\"page\");} }"); + assertFalse(new File(classes, "com/codename1/generated/mvc/Views.class").exists()); + com.codename1.impl.backend.WiringEnvironment environment = + new com.codename1.impl.backend.WiringEnvironment( + com.codename1.backend.Config.of(new Properties(), "test"), + null, + null, + new ArrayList(), + new ArrayList()); + Object wiring = loader.loadClass("sample.BackendWiring").newInstance(); + HttpServer.Handler[] handlers = + (HttpServer.Handler[]) + wiring.getClass() + .getMethod( + "create", + com.codename1.impl.backend.WiringEnvironment.class) + .invoke(wiring, environment); + Object response = null; + for (HttpServer.Handler h : handlers) { + response = h.handle(request("GET", "/static/app.mjs", null, false)); + if (response != null) break; + } + assertNotNull("Assets missing from actual generated wiring", response); + assertEquals("export const ok=true;", body(response)); + assertEquals("text/javascript; charset=utf-8", field(response, "contentType")); + } + + @Test + public void propertyChainsEvaluateReceiversOnceAndKeepLazyBranches() throws Exception { + setup(); + fixtureSources( + Collections.singletonMap( + "sample.Node", + "package sample; public class Node { public int calls; public Node next;" + + " public Node getChild(){calls++;return calls==1 ? next : null;}" + + " public int getCount(){calls++;return 7;} public String" + + " getName(){calls++;return \"leaf\";} }")); + String decl = + ""; + template("chain", decl + ""); + template("equal", decl + ""); + template("lazy", decl + ""); + template( + "loop", + ""); + compile(); + Class node = loader.loadClass("sample.Node"); + Object root = node.newInstance(), middle = node.newInstance(), leaf = node.newInstance(); + node.getField("next").set(root, middle); + node.getField("next").set(middle, leaf); + Model model = new Model().addAttribute("root", root).addAttribute("flag", false); + assertTrue(render("lazy", model).contains("skip")); + assertEquals(0, node.getField("calls").getInt(root)); + assertTrue(render("chain", model).contains("leaf")); + for (Object n : Arrays.asList(root, middle, leaf)) + assertEquals(1, node.getField("calls").getInt(n)); + assertTrue(render("chain", model.addAttribute("root", null)).contains("")); + Object first = node.newInstance(), second = node.newInstance(); + node.getField("next").set(first, node.newInstance()); + node.getField("next").set(second, node.newInstance()); + assertTrue( + render("loop", new Model().addAttribute("roots", Arrays.asList(first, second))) + .contains("leafleaf")); + assertEquals(1, node.getField("calls").getInt(first)); + assertEquals(1, node.getField("calls").getInt(second)); + Object equalRoot = node.newInstance(), equalLeaf = node.newInstance(); + node.getField("next").set(equalRoot, equalLeaf); + assertTrue( + render("equal", new Model().addAttribute("root", equalRoot)) + .contains("true")); + assertEquals(1, node.getField("calls").getInt(equalRoot)); + assertEquals(1, node.getField("calls").getInt(equalLeaf)); + } + + @Test + public void formDestinationsCannotDiscloseCsrfTokens() throws Exception { + setup(); + fixtureSources( + Collections.singletonMap( + "sample.Destination", + "package sample; public class Destination { public int conversions; public" + + " String toString() {return ++conversions == 1 ? \"/save\" :" + + " \"https://external.test/save\";} }")); + String declaration = ""; + template( + "action", + declaration + "
    "); + template( + "attrAction", + declaration + "
    "); + template( + "fragment", + declaration + + ""); + template( + "override", + declaration + + "
    Save"); + template( + "input", + declaration + + "
    "); + template("static", "
    "); + template( + "staticOverride", + "
    Save"); + template( + "base", + "
    "); + template( + "changing", + "
    "); + compile(); + Model model = + new Model() + .addAttribute( + "_csrf", + new com.codename1.backend.security.DefaultCsrfToken( + "X-CSRF-TOKEN", "_csrf", "secret")); + for (String view : + Arrays.asList("action", "attrAction", "override", "input", "fragment :: save")) { + for (String url : + Arrays.asList( + "https://external.test/save", + "http://external.test/save", + "//external.test/save", + "/\\external.test/save", + " https://external.test/save", + "javascript:alert(1)")) { + try { + render(view, model.addAttribute("destination", url)); + fail("Accepted " + view + " destination " + url); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("local absolute path")); + } + } + for (String url : Arrays.asList("/save", "/save?next=a&b=c", "", null)) { + String html = render(view, model.addAttribute("destination", url)); + assertTrue(html, html.contains("name=\"_csrf\" value=\"secret\"")); + if (url != null && url.contains("&")) assertTrue(html, html.contains("a&b=c")); + } + } + Object changingUrl = loader.loadClass("sample.Destination").newInstance(); + String checked = render("changing", model.addAttribute("destination", changingUrl)); + assertTrue(checked, checked.contains("action=\"/save\"")); + assertEquals(1, changingUrl.getClass().getField("conversions").getInt(changingUrl)); + for (String view : Arrays.asList("static", "staticOverride", "base")) { + try { + render(view, model); + fail("Accepted external destination in " + view); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("local absolute path")); + } + } + } + + @Test + public void conditionalNullBranchesRetainReferenceAndCollectionTypes() throws Exception { + setup(); + template( + "conditionalNull", + DECL + + ""); + compile(); + Object product = product("Typed", 1); + Model model = + new Model() + .addAttribute("flag", true) + .addAttribute("product", product) + .addAttribute("products", Arrays.asList(product)); + String html = render("conditionalNull", model); + assertTrue( + html, html.contains("TypedTypedTyped")); + html = render("conditionalNull", model.addAttribute("flag", false)); + assertTrue(html, html.contains("TypedTyped")); + assertFalse(html, html.contains("")); + } + + @Test + public void modelRequirementsFollowSymbolReadsInsteadOfGeneratedText() throws Exception { + setup(); + StringBuilder literals = new StringBuilder(); + for (int i = 0; i < 40; i++) literals.append("model").append(i).append(' '); + template( + "usage", + DECL + + "
    static
    " + + "
    " + + "
    "); + template("include", DECL + "
    "); + compile(); + assertTrue(render("usage :: literal", new Model()).contains(literals)); + assertTrue(render("include", new Model()).contains(literals)); + assertTrue( + render( + "usage :: shadow", + new Model() + .addAttribute( + "products", Arrays.asList(product("Local", 1)))) + .contains("Local")); + try { + render("usage :: read", new Model()); + fail("Missing referenced model was accepted"); + } catch (IllegalStateException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("Missing model 'product'")); + } + } + + @Test + public void duplicateModelAttributeNamesFailDuringRouteProcessing() throws Exception { + setup(); + fixtureSources( + Collections.singletonMap( + "sample.Pages", + "package sample; import com.codename1.backend.annotations.*; import" + + " com.codename1.backend.mvc.*; @Controller public class Pages {" + + " @PostMapping(\"/save\") public String" + + " save(@ModelAttribute(\"product\") Product first, BindingResult" + + " firstErrors,@ModelAttribute(\"product\") Product second," + + " BindingResult secondErrors) {return \"edit\";} }")); + RestControllerAnnotationProcessor processor = new RestControllerAnnotationProcessor(); + processor.start(context); + for (AnnotatedClass cls : context.getClassIndex().values()) + processor.processClass(cls, context); + processor.finish(context); + assertTrue(context.getErrors().toString(), context.hasErrors()); + assertTrue( + context.getErrors().toString(), + context.getErrors() + .toString() + .contains("Duplicate @ModelAttribute name 'product'")); + } + + @Test + public void distinctModelAttributeNamesKeepBothObjectsAndResults() throws Exception { + setup(); + HttpServer.Handler handler = + controller( + "package sample; import com.codename1.backend.annotations.*; import" + + " com.codename1.backend.mvc.*; @Controller public class Pages {" + + " @PostMapping(\"/save\") @ResponseBody public String" + + " save(@ModelAttribute(\"first\") Product first, BindingResult" + + " firstErrors,@ModelAttribute(\"second\") Product second," + + " BindingResult secondErrors, Model model) {return" + + " Boolean.toString(first != second && firstErrors != secondErrors &&" + + " model.getAttribute(\"first\") == first &&" + + " model.getAttribute(\"second\") == second &&" + + " model.getAttribute(\"BindingResult.first\") == firstErrors &&" + + " model.getAttribute(\"BindingResult.second\") == secondErrors); }" + + " @PostMapping(\"/other\") @ResponseBody public String" + + " other(@ModelAttribute(\"first\") Product first) {return" + + " first.name;} }"); + assertEquals("true", body(handler.handle(request("POST", "/save", "name=One", false)))); + assertEquals("Two", body(handler.handle(request("POST", "/other", "name=Two", false)))); + } + + @Test + public void dynamicAttributesAreEvaluatedOncePerElement() throws Exception { + setup(); + fixtureSources( + Collections.singletonMap( + "sample.Attributes", + "package sample; public class Attributes { public int methods, verbs," + + " owners, titles; public String getMethod(){return ++methods == 1 ?" + + " \"post\" : \"get\";} public String getVerb(){return ++verbs == 1 ?" + + " null : \"/save\";} public String getOwner(){return ++owners == 1 ?" + + " \"owner\" : \"wrong\";} public String getTitle(){titles++;return" + + " \"title\";} }")); + String declaration = ""; + template( + "methodOnce", + declaration + "
    "); + template( + "controlOnce", + declaration + + "Save"); + template("verbOnce", declaration + "
    "); + template("nullMethod", declaration + "
    "); + template( + "skipped", declaration + "
    "); + template( + "loopOnce", + declaration + + "" + + "
    "); + compile(); + Model model = csrfModel(); + Object attributes = loader.loadClass("sample.Attributes").newInstance(); + org.jsoup.nodes.Document html = + org.jsoup.Jsoup.parse(render("methodOnce", model.addAttribute("a", attributes))); + assertEquals("post", html.select("form").attr("method")); + assertEquals("secret", html.select("form input[name=_csrf]").val()); + assertEquals(1, attributes.getClass().getField("methods").getInt(attributes)); + assertEquals(1, attributes.getClass().getField("titles").getInt(attributes)); + attributes = loader.loadClass("sample.Attributes").newInstance(); + html = org.jsoup.Jsoup.parse(render("controlOnce", model.addAttribute("a", attributes))); + assertEquals("post", html.select("button").attr("formmethod")); + assertEquals("owner", html.select("button").attr("form")); + assertEquals("secret", html.select("input[name=_csrf][form=owner]").val()); + assertEquals(1, attributes.getClass().getField("methods").getInt(attributes)); + assertEquals(1, attributes.getClass().getField("owners").getInt(attributes)); + attributes = loader.loadClass("sample.Attributes").newInstance(); + html = org.jsoup.Jsoup.parse(render("verbOnce", model.addAttribute("a", attributes))); + assertFalse(html.select("form").hasAttr("hx-post")); + assertTrue(html.select("input[name=_csrf]").isEmpty()); + assertEquals(1, attributes.getClass().getField("verbs").getInt(attributes)); + attributes = loader.loadClass("sample.Attributes").newInstance(); + html = org.jsoup.Jsoup.parse(render("nullMethod", model.addAttribute("a", attributes))); + assertFalse(html.select("form").hasAttr("method")); + assertTrue(html.select("input[name=_csrf]").isEmpty()); + assertEquals(1, attributes.getClass().getField("verbs").getInt(attributes)); + attributes = loader.loadClass("sample.Attributes").newInstance(); + render("skipped", model.addAttribute("a", attributes)); + assertEquals(0, attributes.getClass().getField("methods").getInt(attributes)); + attributes = loader.loadClass("sample.Attributes").newInstance(); + html = + org.jsoup.Jsoup.parse( + render( + "loopOnce", + model.addAttribute("a", attributes) + .addAttribute("items", Arrays.asList("one", "two")))); + assertEquals("post", html.select("form").get(0).attr("method")); + assertEquals("get", html.select("form").get(1).attr("method")); + assertEquals(1, html.select("input[name=_csrf]").size()); + assertEquals(2, attributes.getClass().getField("methods").getInt(attributes)); + } + + private static Model csrfModel() { + return new Model() + .addAttribute( + "_csrf", + new com.codename1.backend.security.DefaultCsrfToken( + "X-CSRF-TOKEN", "_csrf", "secret")); + } + + @Test + public void mutatingHtmxDestinationsMustStayLocal() throws Exception { + setup(); + int i = 0; + for (String verb : Arrays.asList("post", "put", "patch", "delete")) { + for (String prefix : Arrays.asList("hx-", "data-hx-")) { + template( + "dynamic" + i, + "
    "); + template( + "static" + i, + "
    "); + i++; + } + } + compile(); + for (int n = 0; n < i; n++) { + for (String url : + Arrays.asList( + "https://external.test/save", + "//external.test/save", + "/\\external.test/save")) { + try { + render("dynamic" + n, csrfModel().addAttribute("url", url)); + fail("Accepted " + url); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("local absolute path")); + } + } + try { + render("static" + n, csrfModel()); + fail("Accepted static external htmx destination"); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("local absolute path")); + } + assertTrue( + render("dynamic" + n, csrfModel().addAttribute("url", "/save")) + .contains("name=\"_csrf\"")); + } + } + + @Test + public void htmxGetFiltersCsrfAndRetainsNativePostFallback() throws Exception { + setup(); + template( + "preview", + "
    "); + template( + "inherited", + "
    Save
    "); + template("parts", ""); + compile(); + for (String params : + Arrays.asList(null, "", "*", "none", "not other", "title,_csrf", "_csrf")) { + org.jsoup.nodes.Document html = + org.jsoup.Jsoup.parse( + render( + "preview", + csrfModel() + .addAttribute("url", "/preview") + .addAttribute("params", params))); + assertEquals("post", html.select("form").attr("method")); + assertEquals("secret", html.select("input[name=_csrf]").val()); + String expected = + params == null || params.isEmpty() || params.equals("*") + ? "not _csrf" + : params.equals("not other") + ? "not other,_csrf" + : params.equals("title,_csrf") ? "title" : "none"; + assertEquals(expected, html.select("form").attr("hx-params")); + } + org.jsoup.nodes.Document html = org.jsoup.Jsoup.parse(render("inherited", csrfModel())); + assertEquals("title", html.select("form").attr("hx-params")); + for (org.jsoup.nodes.Element button : html.select("button")) + assertEquals("title,_csrf", button.attr("hx-params")); + Model custom = + new Model() + .addAttribute( + "_csrf", + new com.codename1.backend.security.DefaultCsrfToken( + "X-TOKEN", "customToken", "secret")) + .addAttribute("url", "/preview") + .addAttribute("params", "*"); + html = org.jsoup.Jsoup.parse(render("preview", custom)); + assertEquals("not customToken", html.select("form").attr("hx-params")); + assertEquals("secret", html.select("input[name=customToken]").val()); + } + + @Test + public void linkResourceUrlsMustBeStatic() throws Exception { + setup(); + for (String link : + Arrays.asList( + "", + "", + "")) { + template( + "link", + "" + + link); + try { + new MvcTemplates(context).sources(); + fail("Accepted dynamic link resource URL"); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("Dynamic link resource URLs")); + } + } + template( + "link", + "Link"); + compile(); + assertTrue(render("link", new Model()).contains("href=\"/static/catalog.css\"")); + assertTrue(render("link", new Model()).contains("href=\"https://example.test/\"")); + } + + @Test + public void nativeGetSubmitOverridesCannotDiscloseCsrfTokens() throws Exception { + setup(); + String declaration = ""; + template( + "buttonGet", + "
    "); + template( + "inputGet", + "
    "); + template( + "ownedGet", + "
    "); + template( + "dynamicGet", + declaration + + "
    Preview"); + template( + "fragmentGet", + declaration + + ""); + template( + "separateGet", + "
    "); + compile(); + for (String view : + Arrays.asList( + "buttonGet", + "inputGet", + "ownedGet", + "dynamicGet", + "fragmentGet :: preview")) { + try { + render(view, csrfModel().addAttribute("method", "get")); + fail("Accepted GET override with CSRF token: " + view); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("GET submit-method overrides")); + } + assertTrue( + render(view, new Model().addAttribute("method", "get")) + .contains("formmethod=")); + } + for (String method : Arrays.asList("", "unknown", " post ")) { + try { + render("dynamicGet", csrfModel().addAttribute("method", method)); + fail("Accepted override that defaults to GET: " + method); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("GET submit-method overrides")); + } + } + for (String method : Arrays.asList("post", "POST", "dialog", null)) { + assertTrue( + render("dynamicGet", csrfModel().addAttribute("method", method)) + .contains("name=\"_csrf\"")); + } + org.jsoup.nodes.Document html = org.jsoup.Jsoup.parse(render("separateGet", csrfModel())); + assertEquals("secret", html.select("form[method=post] input[name=_csrf]").val()); + assertTrue(html.select("form[method=get] input[name=_csrf]").isEmpty()); + } + + @Test + public void metaHttpEquivDirectivesMustBeStatic() throws Exception { + setup(); + for (String meta : + Arrays.asList( + "", + "", + "")) { + template( + "meta", + "" + + meta); + try { + new MvcTemplates(context).sources(); + fail("Accepted dynamic meta directive"); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("Dynamic meta http-equiv directives")); + } + } + template( + "meta", + ""); + compile(); + String html = render("meta", new Model().addAttribute("description", "Search & browse")); + assertTrue(html, html.contains("content=\"Search & browse\"")); + assertTrue(html, html.contains("content=\"5;URL=/products\"")); + } + + @Test + public void formEncodingsMustBeSupportedByRequestBinding() throws Exception { + setup(); + for (String element : + Arrays.asList( + "
    ", + "", + "")) { + template("encoding", element); + try { + new MvcTemplates(context).sources(); + fail("Accepted unsupported static encoding: " + element); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("Unsupported form encoding")); + } + } + template( + "encoding", + "
    Save"); + template( + "buttonEncoding", + "
    Save"); + template( + "inputEncoding", + "
    "); + compile(); + for (String view : Arrays.asList("encoding", "buttonEncoding", "inputEncoding")) { + for (String encoding : Arrays.asList("text/plain", "TEXT/PLAIN", "application/json")) { + try { + render(view, csrfModel().addAttribute("encoding", encoding)); + fail("Accepted dynamic encoding: " + encoding); + } catch (IllegalArgumentException expected) { + assertTrue( + expected.getMessage(), + expected.getMessage().contains("Unsupported form encoding")); + } + } + for (String encoding : + Arrays.asList( + "application/x-www-form-urlencoded", + "multipart/form-data", + "MULTIPART/FORM-DATA", + "", + null)) { + String html = render(view, csrfModel().addAttribute("encoding", encoding)); + assertTrue(html, html.contains("name=\"_csrf\"")); + if (encoding != null) + assertTrue(html, html.contains("formenctype=\"" + encoding + "\"")); + else assertFalse(html, html.contains("enctype=")); + } + } + } + + @Test + public void htmxMutationsUseHeadersAndDeleteExcludesUrlTokens() throws Exception { + setup(); + template( + "mutations", + "Patch
    Get
    "); + template("scriptHeaders", ""); + template( + "externalGet", + "
    Get"); + compile(); + Model model = csrfModel().addAttribute("url", "/save"); + org.jsoup.nodes.Document html = org.jsoup.Jsoup.parse(render("mutations", model)); + for (String id : Arrays.asList("post", "put", "patch", "delete", "deleteForm", "dynamic")) { + Map headers = + com.codename1.backend.Json.parseObject( + html.getElementById(id).attr("hx-headers")); + assertEquals(id, "secret", headers.get("X-CSRF-TOKEN")); + assertFalse(headers.containsKey("x-csrf-token")); + } + assertEquals( + "keep", + com.codename1.backend.Json.parseObject( + html.getElementById("post").attr("hx-headers")) + .get("X-Auth")); + assertEquals("none", html.getElementById("post").attr("hx-params")); + for (String id : Arrays.asList("delete", "deleteForm", "dynamic", "nestedGet")) + assertEquals(id, "not _csrf", html.getElementById(id).attr("hx-params")); + assertEquals("secret", html.select("#deleteForm input[name=_csrf]").val()); + html = org.jsoup.Jsoup.parse(render("mutations", model.addAttribute("url", null))); + assertFalse(html.getElementById("dynamic").hasAttr("hx-headers")); + for (String view : Arrays.asList("scriptHeaders", "externalGet")) { + try { + render(view, model); + fail("Accepted " + view); + } catch (IllegalArgumentException expected) { + assertNotNull(expected.getMessage()); + } + } + assertTrue(render("scriptHeaders", new Model()).contains("js:({})")); + Model custom = + new Model() + .addAttribute("url", "/save") + .addAttribute( + "_csrf", + new com.codename1.backend.security.DefaultCsrfToken( + "X-Custom", "token", "a\"<&")); + html = org.jsoup.Jsoup.parse(render("mutations", custom)); + assertEquals( + "a\"<&", + com.codename1.backend.Json.parseObject( + html.getElementById("delete").attr("hx-headers")) + .get("X-Custom")); + assertEquals("not token", html.getElementById("delete").attr("hx-params")); + for (String params : Arrays.asList("*", "none", "title,_csrf", "not title", "_csrf")) { + String filtered = + com.codename1.backend.mvc.Html.htmxParameters(csrfModel(), true, params); + assertEquals( + params.equals("*") + ? "not _csrf" + : params.equals("title,_csrf") + ? "title" + : params.equals("not title") ? "not title,_csrf" : "none", + filtered); + } + } + + @Test + public void implicitOptionTextIsEvaluatedAndStringifiedOnce() throws Exception { + setup(); + fixtureSources( + Collections.singletonMap( + "sample.Label", + "package sample; public class Label { public int reads; public int strings;" + + " public Object getValue() { reads++; return new Object() { public" + + " String toString() { return ++strings == 1 ? \" A & B \" :" + + " \"Different\"; } }; } }")); + template( + "once", + DECL + + "" + + ""); + compile(); + Object label = loader.loadClass("sample.Label").newInstance(); + org.jsoup.nodes.Element option = + org.jsoup.Jsoup.parse( + render( + "once", + new Model() + .addAttribute("product", product("A & B", 0)) + .addAttribute("label", label))) + .select("option") + .first(); + assertTrue(option.hasAttr("selected")); + assertEquals("A & B", option.text()); + assertEquals(1, label.getClass().getField("reads").getInt(label)); + assertEquals(1, label.getClass().getField("strings").getInt(label)); + } + + @Test + public void primitiveArraysCompileAndRenderWithIterationStatus() throws Exception { + setup(); + String[] types = {"boolean", "byte", "short", "int", "long", "char", "float", "double"}; + Object[] arrays = { + new boolean[] {true, false}, + new byte[] {1, 2}, + new short[] {1, 2}, + new int[] {1, 2}, + new long[] {1, 2}, + new char[] {'a', 'b'}, + new float[] {1, 2}, + new double[] {1, 2} + }; + fixtureSources( + Collections.singletonMap( + "sample.Numbers", + "package sample; public class Numbers {" + + " public int[] getValues() { return new int[]{3, 4}; } }")); + for (String type : types) + template( + type + "Array", + "

    "); + template( + "getter", + "

    "); + compile(); + for (int i = 0; i < types.length; i++) { + org.jsoup.select.Elements rows = + org.jsoup.Jsoup.parse( + render( + types[i] + "Array", + new Model().addAttribute("values", arrays[i]))) + .select("p"); + assertEquals(types[i], 2, rows.size()); + for (int j = 0; j < 2; j++) { + assertEquals( + String.valueOf(java.lang.reflect.Array.get(arrays[i], j)), + rows.get(j).text()); + assertEquals(String.valueOf(j), rows.get(j).attr("data-index")); + assertEquals(String.valueOf(j == 1), rows.get(j).attr("data-last")); + } + assertTrue( + org.jsoup.Jsoup.parse( + render( + types[i] + "Array", + new Model().addAttribute("values", null))) + .select("p") + .isEmpty()); + assertTrue( + org.jsoup.Jsoup.parse( + render( + types[i] + "Array", + new Model() + .addAttribute( + "values", + java.lang.reflect.Array.newInstance( + arrays[i] + .getClass() + .getComponentType(), + 0)))) + .select("p") + .isEmpty()); + } + assertEquals( + "3 4", + org.jsoup.Jsoup.parse( + render( + "getter", + new Model() + .addAttribute( + "numbers", + loader.loadClass("sample.Numbers") + .newInstance()))) + .body() + .text()); + } + + private static volatile int benchmarkSink; + + @Test + public void renderingBenchmark() throws Exception { + org.junit.Assume.assumeTrue(Boolean.getBoolean("cn1.mvc.benchmark")); + setup(); + template("bench", "

    "); + Map sources = new MvcTemplates(context).sources(); + String common = + "package sample; import com.codename1.backend.*; import" + + " com.codename1.backend.mvc.*; "; + sources.put( + "sample.Compiled", + common + + "public class Compiled implements java.util.concurrent.Callable {" + + " private final Model model=new Model().addAttribute(\"name\",\"Hello" + + " ๐Ÿ˜€\"); public byte[] call(){return" + + " com.codename1.generated.mvc.Views.render(\"bench\",model);} }"); + sources.put( + "sample.Handwritten", + common + + "public class Handwritten implements" + + " java.util.concurrent.Callable { private final Model model=new" + + " Model().addAttribute(\"name\",\"Hello ๐Ÿ˜€\"); private static" + + " final byte[]" + + " BEFORE=Html.utf8(\"

    \"),AFTER=Html.utf8(\"

    \");" + + " public byte[] call(){ByteSink out=new ByteSink(1024);Object" + + " raw=Html.require(model,\"name\",\"bench\");if(raw!=null && !(raw" + + " instanceof String))throw new" + + " IllegalStateException();out.put(BEFORE,0,BEFORE.length);Html.text(out,(String)raw);out.put(AFTER,0,AFTER.length);return" + + " Html.bytes(out);} }"); + List cp = new ArrayList(classpath()); + cp.add(classes); + JavaSourceCompiler.compile(sources, classes, cp); + loader = + new URLClassLoader( + new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); + Callable compiled = + (Callable) loader.loadClass("sample.Compiled").newInstance(); + Callable handwritten = + (Callable) loader.loadClass("sample.Handwritten").newInstance(); + assertArrayEquals(handwritten.call(), compiled.call()); + for (int i = 0; i < 30000; i++) { + benchmarkSink = compiled.call().length; + benchmarkSink = handwritten.call().length; + } + com.sun.management.ThreadMXBean bean = + (com.sun.management.ThreadMXBean) + java.lang.management.ManagementFactory.getThreadMXBean(); + if (bean.isThreadAllocatedMemorySupported()) bean.setThreadAllocatedMemoryEnabled(true); + long thread = Thread.currentThread().getId(); + int count = 100000; + StringBuilder rows = + new StringBuilder( + "renderer,trial,ns_per_render,renders_per_second,allocated_bytes_per_render\n"); + for (int trial = 0; trial < 7; trial++) + for (int order = 0; order < 2; order++) { + boolean generated = (trial + order) % 2 == 0; + Callable renderer = generated ? compiled : handwritten; + long allocation = + bean.isThreadAllocatedMemorySupported() + ? bean.getThreadAllocatedBytes(thread) + : -1; + long start = System.nanoTime(); + for (int i = 0; i < count; i++) { + byte[] bytes = renderer.call(); + benchmarkSink = bytes.length + bytes[bytes.length - 1]; + } + long elapsed = System.nanoTime() - start; + double allocated = + allocation < 0 + ? -1 + : (bean.getThreadAllocatedBytes(thread) - allocation) + / (double) count; + rows.append(generated ? "compiled" : "handwritten") + .append(',') + .append(trial) + .append(',') + .append(elapsed / (double) count) + .append(',') + .append(count * 1e9 / elapsed) + .append(',') + .append(allocated) + .append('\n'); + } + File output = new File("target/mvc-render-benchmark.csv"); + Files.write(output.toPath(), rows.toString().getBytes(StandardCharsets.UTF_8)); + System.out.println("MVC render benchmark: " + output.getAbsolutePath()); + } + + static HttpServer.Request request(String method, String target, String body, boolean hx) + throws Exception { + return request(method, target, body, hx, false); + } + + static HttpServer.Request request( + String method, String target, String body, boolean hx, boolean history) + throws Exception { + String head = + method + + " " + + target + + " HTTP/1.1\r\nContent-Type: application/x-www-form-urlencoded\r\n" + + (hx ? "HX-Request: true\r\n" : "") + + (history ? "HX-History-Restore-Request: true\r\n" : "") + + "\r\n"; + byte[] raw = head.getBytes(StandardCharsets.UTF_8); + List slices = new ArrayList(); + int pos = head.indexOf("\r\n") + 2; + while (head.charAt(pos) != '\r') { + int colon = head.indexOf(':', pos), end = head.indexOf("\r\n", pos); + slices.add(pos); + slices.add(colon - pos); + slices.add(colon + 2); + slices.add(end - colon - 2); + pos = end + 2; + } + int[] indices = new int[slices.size()]; + for (int i = 0; i < indices.length; i++) indices[i] = slices.get(i); + Constructor ctor = + HttpServer.Request.class.getDeclaredConstructor( + String.class, + String.class, + String.class, + byte[].class, + int[].class, + int.class, + String.class, + int.class, + int.class); + ctor.setAccessible(true); + return (HttpServer.Request) + ctor.newInstance( + method, + target, + "HTTP/1.1", + raw, + indices, + indices.length / 4, + body, + method.length() + 1, + target.getBytes(StandardCharsets.UTF_8).length); + } + + static Object field(Object o, String name) throws Exception { + Field f = o.getClass().getDeclaredField(name); + f.setAccessible(true); + return f.get(o); + } + + static String body(Object response) throws Exception { + return new String((byte[]) field(response, "body"), StandardCharsets.UTF_8); + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java index 0dd5088f703..d69d6a9cd4f 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java @@ -238,7 +238,8 @@ private static void backendSettingsInputs(Project project, org.gradle.api.Task c compile.getInputs().files(project.fileTree(layout.projectDir(), tree -> { tree.include("application.properties", "application-*.properties", "src/main/resources/application.properties", - "src/main/resources/application-*.properties"); + "src/main/resources/application-*.properties", + "src/main/resources/templates/**/*.html", "src/main/resources/static/**"); })).withPropertyName("cn1BackendSettings") .withPathSensitivity(org.gradle.api.tasks.PathSensitivity.RELATIVE); } diff --git a/scripts/backend-mvc/HTMX-LICENSE.txt b/scripts/backend-mvc/HTMX-LICENSE.txt new file mode 100644 index 00000000000..d3061a372fd --- /dev/null +++ b/scripts/backend-mvc/HTMX-LICENSE.txt @@ -0,0 +1,13 @@ +Zero-Clause BSD +============= + +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED โ€œAS ISโ€ AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/scripts/backend-mvc/README.md b/scripts/backend-mvc/README.md new file mode 100644 index 00000000000..60735314139 --- /dev/null +++ b/scripts/backend-mvc/README.md @@ -0,0 +1,83 @@ +# Compiled HTML catalog + +A complete in-memory CRUD application using backend MVC controllers, typed HTML, +shared fragments, scalar form binding, CSRF protection, and htmx. Restarting the +server resets the catalog. It works with ordinary forms and with htmx updates. + +Requires the backend and build plugins from this checkout installed locally. +Maven uses Java 8. Gradle itself requires Java 17 or newer. + +From this directory: + +```sh +mvn package +mvn com.codenameone:codenameone-maven-plugin:8.0-SNAPSHOT:backend +``` + +Open `http://localhost:8080/products`. To build and run the native executable: + +```sh +mvn com.codenameone:codenameone-maven-plugin:8.0-SNAPSHOT:backend-package +./target/backend-mvc +``` + +The generated executable contains both the renderers and public assets. It can +run from another directory with only an `application.properties` file (or the +usual backend environment configuration). It does not read HTML files at runtime. + +The included Gradle build uses the same sources and template compiler: + +```sh +gradle classes +gradle runBackend +gradle backendPackage +``` + +If using a custom Maven local repository, pass its path as +`-Pcodename1.repository=/path/to/repository` to Gradle and configure the plugin +repository in `settings.gradle.kts` to match. Template and asset edits invalidate +Gradle compilation, including with its configuration cache enabled. + +## Verify + +With either server running: + +```sh +python3 smoke.py http://127.0.0.1:8080 +``` + +The script creates and removes its own test products, checks both normal and +htmx request flows, invalid form redisplay, CSRF rejection, Unicode escaping, +embedded assets, and inaccessible template sources. + +Compiler, router, and generated test-context regressions run in the build engine: + +```sh +cd ../../maven +mvn -pl backend,build-engine -am test -Plocal-dev-javase \ + -Dtest=MvcTemplatesTest,RestControllerAnnotationProcessorTest,BackendBeansTest,BackendTestGeneratorTest \ + -Dsurefire.failIfNoSpecifiedTests=false -Dmaven.javadoc.skip=true +``` + +The optional `-Dcn1.mvc.benchmark=true` adds a rendering comparison and writes +`build-engine/target/mvc-render-benchmark.csv`. It compares identical escaped HTML +from generated and handwritten Java renderers, using the same input and buffer +size, with warmup and alternating trial order. It records time, throughput, and +thread allocation. It is a small JavaSE microbenchmark, not a native HTTP or +cross-framework performance claim; concurrent builds and garbage collection can +substantially affect its timings. + +## Where to start + +- `Catalog.java`: view names, `Model`, `@ModelAttribute`, `BindingResult`, and htmx selection. +- `templates/products.html`: typed iteration, URL generation, named fragments, and deletion forms. +- `templates/edit.html`: typed form fields, conversion errors, and shared layout fragments. +- `WebSecurity.java`: public access with CSRF enforcement enabled. + +The complete syntax and migration limits are in +[the backend views chapter](../../docs/developer-guide/Backend-Views.asciidoc). +This is a practical Thymeleaf/Spring MVC subset; it does not embed either framework. + +htmx 2.0.11 is vendored from the official npm distribution through jsDelivr: +`https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js`. +Its Zero-Clause BSD license is in `HTMX-LICENSE.txt`. diff --git a/scripts/backend-mvc/application.properties b/scripts/backend-mvc/application.properties new file mode 100644 index 00000000000..b20f4c55075 --- /dev/null +++ b/scripts/backend-mvc/application.properties @@ -0,0 +1,3 @@ +cn1.server.port=8080 +cn1.session.store=memory +cn1.session.same-site=Lax diff --git a/scripts/backend-mvc/build.gradle.kts b/scripts/backend-mvc/build.gradle.kts new file mode 100644 index 00000000000..7166fa8625c --- /dev/null +++ b/scripts/backend-mvc/build.gradle.kts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +// The settings plugin configures this backend-only project. +// gradle runBackend runs generated views on the JVM +// gradle backendPackage builds a native executable diff --git a/scripts/backend-mvc/pom.xml b/scripts/backend-mvc/pom.xml new file mode 100644 index 00000000000..7501990d9f6 --- /dev/null +++ b/scripts/backend-mvc/pom.xml @@ -0,0 +1,12 @@ + + 4.0.0 + com.codenameone.examplesbackend-mvc1.0-SNAPSHOT + 8.0-SNAPSHOT1.81.8UTF-8 + com.codenameonecodenameone-backend${cn1.version} + + org.apache.maven.pluginsmaven-surefire-plugin3.2.5 + org.apache.maven.pluginsmaven-jar-plugin3.3.0 + org.apache.maven.pluginsmaven-compiler-plugin3.8.0 + com.codenameonecodenameone-maven-plugin${cn1.version}viewsprocess-classesprocess-annotations + + diff --git a/scripts/backend-mvc/settings.gradle.kts b/scripts/backend-mvc/settings.gradle.kts new file mode 100644 index 00000000000..77c35733460 --- /dev/null +++ b/scripts/backend-mvc/settings.gradle.kts @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +pluginManagement { + repositories { + mavenLocal() + maven("https://repo.codenameone.com/maven2") + gradlePluginPortal() + } + resolutionStrategy { + eachPlugin { + if (requested.id.id == "com.codenameone") { + useModule("com.codenameone:codenameone-gradle-plugin:${requested.version}") + } + } + } +} +plugins { id("com.codenameone") version "8.0-SNAPSHOT" } +rootProject.name = "backend-mvc" diff --git a/scripts/backend-mvc/smoke.py b/scripts/backend-mvc/smoke.py new file mode 100644 index 00000000000..3e369492446 --- /dev/null +++ b/scripts/backend-mvc/smoke.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Exercise the running demo over HTTP, with ordinary forms and htmx requests.""" +import html.parser +import http.cookiejar +import json +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid + + +class Page(html.parser.HTMLParser): + def __init__(self, text): + super().__init__() + self.token = None + self.csrf_headers = None + self.links = [] + self.feed(text) + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if "hx-headers" in attrs: + self.csrf_headers = json.loads(attrs["hx-headers"]) + if tag == "input" and attrs.get("name") == "_csrf": + self.token = attrs["value"] + if tag == "a" and attrs.get("href", "").startswith("/products/"): + self.links.append(attrs["href"]) + + +base = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8080" +client = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) + + +def request(path, data=None, hx=False, csrf_headers=None): + headers = {"HX-Request": "true"} if hx else {} + headers.update(csrf_headers or {}) + payload = urllib.parse.urlencode(data).encode() if data is not None else None + req = urllib.request.Request(base + path, data=payload, headers=headers) + try: + response = client.open(req) + except urllib.error.HTTPError as error: + response = error + return response.status, response.read().decode(), response.headers + + +def submit(path, data, page, hx): + security = Page(page) + if hx: + assert security.csrf_headers, page + assert security.csrf_headers.get("X-CSRF-TOKEN") == security.token, page + # Exercise header-only CSRF, as standalone htmx controls need it. + return request(path, data, hx, security.csrf_headers) + data["_csrf"] = security.token + return request(path, data, hx) + + +for hx in (False, True): + status, page, _ = request("/products/new", hx=hx) + assert status == 200 and Page(page).token, (status, page) + assert ("" + status, listing, _ = submit("/products", { + "name": name, "quantity": "3", "_active": "on", "active": "true"}, invalid, hx) + assert status == 200 and "<&๐Ÿ˜€>" in listing, listing + assert (" 50000 and "javascript" in headers["Content-Type"] +assert request("/templates/edit.html")[0] == 404 +assert request("/static/../templates/edit.html")[0] in (400, 404) +print("PASS packaged assets and private templates") diff --git a/scripts/backend-mvc/src/main/java/example/Catalog.java b/scripts/backend-mvc/src/main/java/example/Catalog.java new file mode 100644 index 00000000000..4d832c541f9 --- /dev/null +++ b/scripts/backend-mvc/src/main/java/example/Catalog.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package example; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.*; +import com.codename1.backend.mvc.*; + +import java.util.*; + +/** In-memory demo: restart resets data. All storage access is synchronized. */ +@Controller +public class Catalog { + private final Map products = new LinkedHashMap(); + private int nextId = 1; + + public Catalog() { + products.put(nextId, new Product(nextId++, "Notebook", 12, true)); + } + + @GetMapping("/") + public String home() { + return "redirect:/products"; + } + + @GetMapping("/products") + public synchronized String list(Model model, HttpServer.Request request) { + populate(model); + return Htmx.isRequest(request) ? "products :: catalog" : "products"; + } + + @GetMapping("/products/new") + public String create(Model model, HttpServer.Request request) { + ProductForm form = new ProductForm(); + form.quantity = 1; + form.active = true; + model.addAttribute("form", form) + .addAttribute("action", "/products") + .addAttribute("heading", "Add product"); + return Htmx.isRequest(request) ? "edit :: editor" : "edit"; + } + + @GetMapping("/products/{id}") + public synchronized String edit( + @PathVariable("id") int id, Model model, HttpServer.Request request) { + Product p = products.get(id); + if (p == null) return null; + ProductForm form = new ProductForm(); + form.name = p.getName(); + form.quantity = p.getQuantity(); + form.active = p.isActive(); + model.addAttribute("form", form) + .addAttribute("action", "/products/" + id) + .addAttribute("heading", "Edit product"); + return Htmx.isRequest(request) ? "edit :: editor" : "edit"; + } + + @PostMapping("/products") + public synchronized String add( + @ModelAttribute("form") ProductForm form, + BindingResult errors, + Model model, + HttpServer.Request request) { + validate(form, errors); + if (errors.hasErrors()) return invalid(model, request, "/products", "Add product"); + int id = nextId++; + products.put(id, new Product(id, form.name.trim(), form.quantity, form.active)); + return saved(model, request); + } + + @PostMapping("/products/{id}") + public synchronized String update( + @PathVariable("id") int id, + @ModelAttribute("form") ProductForm form, + BindingResult errors, + Model model, + HttpServer.Request request) { + if (!products.containsKey(id)) return null; + validate(form, errors); + if (errors.hasErrors()) return invalid(model, request, "/products/" + id, "Edit product"); + products.put(id, new Product(id, form.name.trim(), form.quantity, form.active)); + return saved(model, request); + } + + @PostMapping("/products/{id}/delete") + public synchronized String delete( + @PathVariable("id") int id, Model model, HttpServer.Request request) { + if (products.remove(id) == null) return null; + return saved(model, request); + } + + private static void validate(ProductForm form, BindingResult errors) { + if (form.name == null || form.name.trim().isEmpty()) + errors.rejectValue("name", "Enter a product name."); + if (form.quantity < 0) errors.rejectValue("quantity", "Quantity must be zero or more."); + } + + private String invalid(Model model, HttpServer.Request request, String action, String heading) { + model.addAttribute("action", action).addAttribute("heading", heading); + return Htmx.isRequest(request) ? "edit :: editor" : "edit"; + } + + private String saved(Model model, HttpServer.Request request) { + if (!Htmx.isRequest(request)) return "redirect:/products"; + populate(model); + return "products :: catalog"; + } + + private void populate(Model model) { + model.addAttribute("products", new ArrayList(products.values())); + } +} diff --git a/scripts/backend-mvc/src/main/java/example/Product.java b/scripts/backend-mvc/src/main/java/example/Product.java new file mode 100644 index 00000000000..7004921faa6 --- /dev/null +++ b/scripts/backend-mvc/src/main/java/example/Product.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package example; + +public final class Product { + private final int id; + private final String name; + private final int quantity; + private final boolean active; + + public Product(int id, String name, int quantity, boolean active) { + this.id = id; + this.name = name; + this.quantity = quantity; + this.active = active; + } + + public int getId() { + return id; + } + + public String getName() { + return name; + } + + public int getQuantity() { + return quantity; + } + + public boolean isActive() { + return active; + } +} diff --git a/scripts/backend-mvc/src/main/java/example/ProductForm.java b/scripts/backend-mvc/src/main/java/example/ProductForm.java new file mode 100644 index 00000000000..d92005dac19 --- /dev/null +++ b/scripts/backend-mvc/src/main/java/example/ProductForm.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package example; + +/** Dedicated editable fields; identifiers are bound separately from the route. */ +public class ProductForm { + public String name; + public int quantity; + public boolean active; +} diff --git a/scripts/backend-mvc/src/main/java/example/WebSecurity.java b/scripts/backend-mvc/src/main/java/example/WebSecurity.java new file mode 100644 index 00000000000..8b9804a11a5 --- /dev/null +++ b/scripts/backend-mvc/src/main/java/example/WebSecurity.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package example; + +import com.codename1.backend.annotations.*; +import com.codename1.backend.security.*; + +/** Public demo with CSRF protection enabled for every unsafe request. */ +@Configuration +public class WebSecurity { + @Bean + public SecurityFilterChain pages(HttpSecurity http) { + http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } +} diff --git a/scripts/backend-mvc/src/main/resources/static/catalog.css b/scripts/backend-mvc/src/main/resources/static/catalog.css new file mode 100644 index 00000000000..60cf03edc71 --- /dev/null +++ b/scripts/backend-mvc/src/main/resources/static/catalog.css @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +:root{font:16px/1.5 system-ui,sans-serif;color:#183331;background:#f5f7f6}body{max-width:960px;margin:auto;padding:24px}header,.title,.actions{display:flex;align-items:center;justify-content:space-between;gap:18px}header{padding:12px 0 28px;border-bottom:1px solid #dbe3df}header>a{font-weight:750;text-decoration:none;font-size:22px}header span{font-size:13px;color:#5b7067}main{margin-top:40px}h1{margin:0;font-size:32px}p{color:#546960}a{color:#11664f}button,.button{background:#11664f;border:0;border-radius:6px;color:white;padding:10px 17px;text-decoration:none;font:inherit;cursor:pointer}table{margin:24px 0;width:100%;border-collapse:collapse;background:white}th,td{text-align:left;padding:16px;border-bottom:1px solid #dbe3df}th{font-size:13px;color:#546960}td.actions{justify-content:flex-start}td form{margin:0}.quiet{background:none;color:#94423d;padding:0}label{display:block;margin:20px 0 5px}input:not([type=checkbox]):not([type=hidden]){display:block;box-sizing:border-box;width:100%;max-width:480px;padding:10px;border:1px solid #8ba399;border-radius:5px;font:inherit}.check{margin-bottom:24px}.error{color:#9b2926;margin:6px 0;font-size:14px}.error:empty{display:none}main>form>.actions{justify-content:flex-start}@media(max-width:600px){body{padding:16px}header span{display:none}.title{align-items:flex-start}th,td{padding:10px}td.actions{gap:12px}} diff --git a/scripts/backend-mvc/src/main/resources/static/htmx-2.0.11.min.js b/scripts/backend-mvc/src/main/resources/static/htmx-2.0.11.min.js new file mode 100644 index 00000000000..e6b8394acb5 --- /dev/null +++ b/scripts/backend-mvc/src/main/resources/static/htmx-2.0.11.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Y={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=pn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.11"};Y.onLoad=V;Y.process=Bt;Y.on=ye;Y.off=xe;Y.trigger=ae;Y.ajax=In;Y.find=a;Y.findAll=x;Y.closest=g;Y.remove=z;Y.addClass=w;Y.removeClass=S;Y.toggleClass=G;Y.takeClass=W;Y.swap=C;Y.defineExtension=zn;Y.removeExtension=Jn;Y.logAll=$;Y.logNone=_;Y.parseInterval=d;Y._=e;const n={addTriggerHandler:Et,bodyContains:ie,canAccessLocalStorage:U,findThisElement:we,filterValues:xn,swap:C,hasAttribute:s,getAttributeValue:f,getClosestAttributeValue:te,getClosestMatch:A,getExpressionVars:qn,getHeaders:yn,getInputValues:pn,getInternalData:re,getSwapSpecification:vn,getTriggerSpecs:lt,getTarget:Se,makeFragment:D,mergeObjects:se,makeSettleInfo:En,oobSwap:Te,querySelectorExt:ce,settleImmediately:Qt,shouldCancel:dt,triggerEvent:ae,triggerErrorEvent:ue,withExtensions:Vt};const he=["get","post","put","delete","patch"];const R=he.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function Q(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function f(e,t){return Q(e,t)||Q(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ee(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):ee()}function A(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=f(t,n);const o=f(t,"hx-disinherit");var i=f(t,"hx-inherit");if(e!==t){if(Y.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function te(t,n){let r=null;A(t,function(e){return!!(r=o(t,le(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=ee().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Y.config.inlineScriptNonce){t.nonce=Y.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){if(!Y.config.allowScriptTags){e.querySelectorAll("script").forEach(e=>e.remove());return}Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){T(e)}finally{e.remove()}}})}function D(e){e=e.replace(/]*)?)>/gi,'");const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){k(r)}return r}function ne(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function P(e){return typeof e==="function"}function M(e){return t(e,"Object")}function re(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function ie(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function se(e,t){return Object.assign({},e,t)}function v(e){try{return JSON.parse(e)}catch(e){T(e);return null}}function U(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function j(e){try{const t=new URL(e,window.location.href);e=t.pathname+t.search}catch(e){}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return Tn(ee().body,function(){return eval(e)})}function V(t){const e=Y.on("htmx:load",function(e){t(e.detail.elt)});return e}function $(){Y.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Y.logger=null}function a(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return a(ee(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ee(),e)}}function b(){return window}function z(e,t){e=E(e);if(t){b().setTimeout(function(){z(e);e=null},t)}else{u(e).removeChild(e)}}function le(e){return e instanceof Element?e:null}function J(e){return e instanceof HTMLElement?e:null}function K(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function w(e,t,n){e=le(E(e));if(!e){return}if(n){b().setTimeout(function(){w(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function S(e,t,n){let r=le(E(e));if(!r){return}if(n){b().setTimeout(function(){S(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function G(e,t){e=E(e);e.classList.toggle(t)}function W(e,t){e=E(e);oe(e.parentElement.children,function(e){S(e,t)});w(le(e),t)}function g(e,t){e=le(E(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Z(e,t){return e.substring(e.length-t.length)===t}function de(e){const t=e.trim();if(l(t,"<")&&Z(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=E(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=de(o.shift());let e;if(r.indexOf("closest ")===0){e=g(le(t),de(r.slice(8)))}else if(r.indexOf("find ")===0){e=a(p(t),de(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=le(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,de(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=le(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=ge(t,de(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=q(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=p(q(t,!!n));i.push(...F(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ce(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(ee().body,e)[0]}}function E(e,t){if(typeof e==="string"){return a(p(t)||document,e)}else{return e}}function me(e,t,n,r){if(P(t)){return{target:ee().body,event:K(e),listener:t,options:n}}else{return{target:E(e),event:K(t),listener:n,options:r}}}function ye(t,n,r,o){Wn(function(){const e=me(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=P(n);return e?n:r}function xe(t,n,r){Wn(function(){const e=me(t,n,r);e.target.removeEventListener(e.event,e.listener)});return P(n)?n:r}const be=ee().createElement("output");function ve(t,n){const e=te(t,n);if(e){if(e==="this"){return[we(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=le(A(t,function(e){return e!==t&&s(le(e),n)}));if(i){r.push(...ve(i,n))}}if(r.length===0){T('The selector "'+e+'" on '+n+" returned no matches!");return[be]}else{return r}}}}function we(e,t){return le(A(e,function(e){return f(le(e),t)!=null}))}function Se(e){const t=te(e,"hx-target");if(t){if(t==="this"){return we(e,"hx-target")}else{return ce(e,t)}}else{const n=re(e);if(n.boosted){return ee().body}else{return e}}}function Ee(e){return Y.config.attributesToSettle.includes(e)}function Ce(t,n){oe(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ee(e.name)){t.removeAttribute(e.name)}});oe(n.attributes,function(e){if(Ee(e.name)){t.setAttribute(e.name,e.value)}})}function Oe(t,e){const n=Kn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){oe(r,function(e){let t;const n=o.cloneNode(true);t=ee().createDocumentFragment();t.appendChild(n);if(!Oe(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){Re(t);Ve(s,e,e,t,i);He()}oe(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);ue(ee().body,"htmx:oobErrorNoTarget",{content:o,target:n})}return e}function He(){const e=a("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=a("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function Re(e){oe(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=f(e,"id");const n=ee().getElementById(t);if(n!=null){if(e.moveBefore){let e=a("#--htmx-preserve-pantry--");if(e==null){ee().body.insertAdjacentHTML("afterend","
    ");e=a("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function qe(i,e,s){oe(e.querySelectorAll("[id]"),function(t){const n=Q(t,"id");if(n&&n.length>0){const e=p(i);const r=e&&e.querySelector(CSS.escape(t.tagName)+"#"+CSS.escape(n));if(r&&r!==e){const o=t.cloneNode();Ce(t,r);s.tasks.push(function(){Ce(t,o)})}}})}function Ae(e){return function(){S(e,Y.config.addedClass);Bt(le(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=J(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){qe(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;w(le(o),Y.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function _e(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");oe(t,function(e){if(Y.config.allowNestedOobSwaps||e.parentElement===null){const t=f(e,"hx-swap-oob");if(t!=null){Te(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function C(d,p,g,m){if(!m){m={}}let y=null;let n=null;let e=function(){ne(m.beforeSwapCallback);d=E(d);const r=m.contextElement&&m.contextElement.isConnected?q(m.contextElement,false):ee();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=En(d);if(g.swapStyle==="textContent"){d.textContent=p}else{let n=typeof p==="string"?D(p):p;o.title=m.title||n.title;if(m.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(m.selectOOB){const s=m.selectOOB.split(",");for(let t=0;t0){b().setTimeout(n,g.settleDelay)}else{n()}};let t=Y.config.globalViewTransitions;if(g.hasOwnProperty("transition")){t=g.transition}const r=m.contextElement||ee();if(t&&ae(r,"htmx:beforeTransition",m.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){y=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(g?.swapDelay&&g.swapDelay>0){b().setTimeout(e,g.swapDelay)}else{e()}}catch(e){ue(r,"htmx:swapError",m.eventInfo);ne(n);throw e}}function ze(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r)||{};for(const i of Object.keys(o)){let e=o[i];if(M(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=Tn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){ue(ee().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(nt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function ot(e){let t;if(e.length>0&&Qe.test(e[0])){e.shift();t=O(e,et).trim();e.shift()}else{t=O(e,Ke)}return t}const it="input, textarea, select";function st(e,t,n){const r=[];const o=tt(t);do{O(o,Ye);const l=o.length;const c=O(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};O(o,Ye);u.pollInterval=d(O(o,/[,\[\s]/));O(o,Ye);var i=rt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=rt(e,o,"event");if(i){a.eventFilter=i}O(o,Ye);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(O(o,Ke))}else if(f==="from"&&o[0]===":"){o.shift();if(Qe.test(o[0])){var s=ot(o)}else{var s=O(o,Ke);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=ot(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=ot(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(O(o,Ke))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=O(o,Ke)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=ot(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=O(o,Ke)}else{ue(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ye)}r.push(a)}}if(o.length===l){ue(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ye)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function lt(e){const t=f(e,"hx-trigger");let n=[];if(t){const r=Y.config.triggerSpecsCache;n=r&&r[t]||st(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,it)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function ct(e){re(e).cancelled=true}function ut(e,t,n){const r=re(e);r.timeout=b().setTimeout(function(){if(ie(e)&&r.cancelled!==true){if(!gt(n,e,Ut("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ut(e,t,n)}},n.pollInterval)}function at(e){return location.hostname===e.hostname&&Q(e,"href")&&Q(e,"href").indexOf("#")!==0}function ft(e){return g(e,Y.config.disableSelector)}function ht(t,n,e){if(t instanceof HTMLAnchorElement&&at(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(Q(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=Q(t,"href")}else{const i=Q(t,"method");r=i?i.toLowerCase():"get";o=Q(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){mt(t,function(e,t){const n=le(e);if(ft(n)){y(n);return}fe(r,o,n,t)},n,e,true)})}}function dt(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function pt(e,t){return re(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;ue(ee().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function mt(l,c,e,u,a){const f=re(l);let t;if(u.from){t=m(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}oe(t,function(i){const s=function(e){if(!ie(l)){i.removeEventListener(u.trigger,s);return}if(pt(l,e)){return}if(a||dt(e,i)){e.preventDefault()}if(gt(u,l,e)){return}const t=re(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(le(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=e.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){ae(l,"htmx:trigger");c(l,e);f.throttle=b().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=b().setTimeout(function(){ae(l,"htmx:trigger");c(l,e)},u.delay)}else{ae(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let yt=false;let xt=null;function bt(){if(!xt){xt=function(){yt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(yt){yt=false;oe(ee().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){vt(e)})}},200)}}function vt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=re(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function wt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){b().setTimeout(o,r)}else{o()}}function St(t,n,e){let i=false;oe(he,function(r){if(s(t,"hx-"+r)){const o=f(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){Et(t,e,n,function(e,t){const n=le(e);if(ft(n)){y(n);return}fe(r,o,n,t)})})}});return i}function Et(r,e,t,n){if(e.trigger==="revealed"){bt();mt(r,n,t,e);vt(le(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ce(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ut(le(r),n,e)}else{mt(r,n,t,e)}}function Ct(e){const t=le(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function qt(e){const t=Nt(e.target);const n=Lt(e);if(n){n.lastButtonClicked=t}}function At(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Nt(e){return g(le(e),"button, input[type='submit']")}function It(e){return e.form||g(e,"form")}function Lt(e){const t=Nt(e.target);if(!t){return}const n=It(t);if(!n){return}return re(n)}function kt(e){e.addEventListener("click",qt);e.addEventListener("focusin",qt);e.addEventListener("focusout",At)}function Dt(t,e,n){const r=re(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){Tn(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){ke(t);for(let e=0;eY.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){ue(ee().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Kt(t){if(!U()){return null}t=j(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(ee().body,"htmx:historyCacheMissLoad",r);C(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});_t(r.path);ae(ee().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{ue(ee().body,"htmx:historyCacheMissLoadError",r)}};if(ae(ee().body,"htmx:historyCacheMiss",r)){t.send()}}function tn(e){Wt();e=e||location.pathname+location.search;const t=Kt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:zt(),swapSpec:n};if(ae(ee().body,"htmx:historyCacheHit",r)){C(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});_t(r.path);ae(ee().body,"htmx:historyRestore",r)}}else{if(Y.config.refreshOnHistoryMiss){Y.location.reload(true)}else{en(e)}}}function nn(e){let t=ve(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){const t=re(e);t.requestCount=(t.requestCount||0)+1;w(e,Y.config.requestClass)});return t}function rn(e){let t=ve(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){const t=re(e);t.requestCount=(t.requestCount||0)+1;if(!e.hasAttribute("disabled")){e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")}});return t}function on(e,t){oe(e.concat(t),function(e){const t=re(e);t.requestCount=(t.requestCount||1)-1});oe(e,function(e){const t=re(e);if(t.requestCount===0){S(e,Y.config.requestClass)}});oe(t,function(e){const t=re(e);if(t.requestCount===0&&e.hasAttribute("data-disabled-by-htmx")){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function sn(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);oe(e,e=>r.append(t,e))}}function an(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||sn(t,e)){return}else{t.push(e)}if(ln(e)){const i=Q(e,"name");cn(i,an(e),n);if(o){hn(e,r)}}if(e instanceof HTMLFormElement){oe(e.elements,function(e){if(t.indexOf(e)>=0){un(e.name,an(e),n)}else{t.push(e)}if(o){hn(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}cn(t,e,n)})}}function hn(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Y.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function dn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function pn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=re(e);if(s.lastButtonClicked&&!ie(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||f(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,It(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&Q(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=Q(u,"name");cn(a,u.value,o)}const c=ve(e,"hx-include");oe(c,function(e){fn(n,r,i,le(e),l);if(!h(e,"form")){oe(p(e).querySelectorAll(it),function(e){fn(n,r,i,e,l)})}});dn(r,o);return{errors:i,formData:r,values:Mn(r)}}function gn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function mn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=gn(n,t,e)});return n}function yn(e,t,n){const r={"HX-Request":"true","HX-Trigger":Q(e,"id"),"HX-Trigger-Name":Q(e,"name"),"HX-Target":f(t,"id"),"HX-Current-URL":location.href};On(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(re(e).boosted){r["HX-Boosted"]="true"}return r}function xn(n,e){const t=te(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){oe(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;oe(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function bn(e){return!!Q(e,"href")&&Q(e,"href").indexOf("#")>=0}function vn(e,t){const n=t||te(e,"hx-swap");const r={swapStyle:re(e).boosted?"innerHTML":Y.config.defaultSwapStyle,swapDelay:Y.config.defaultSwapDelay,settleDelay:Y.config.defaultSettleDelay};if(Y.config.scrollIntoViewOnBoost&&re(e).boosted&&!bn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{T("Unknown modifier in hx-swap: "+l)}}}}return r}function wn(e){return te(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&Q(e,"enctype")==="multipart/form-data"}function Sn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(wn(n)){return dn(new FormData,Dn(r))}else{return mn(r)}}}function En(e){return{tasks:[],elts:[e]}}function Cn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=le(ce(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){b().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=le(ce(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Y.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Y.config.scrollBehavior})}}}function On(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=f(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=Tn(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const c of Object.keys(n)){if(i[c]==null){i[c]=n[c]}}}return On(le(u(r)),e,o,i,s)}function Tn(e,t,n){if(Y.config.allowEval){return t()}else{ue(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return On(e,"hx-vars",true,n,t)}function Rn(e,t,n){return On(e,"hx-vals",false,n,t)}function qn(e,t){return se(Hn(e,t),Rn(e,t))}function An(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function Nn(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){ue(ee().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function H(e,t){return e.getResponseHeader(t)!==null}function In(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return fe(t,n,null,null,{targetOverride:E(r)||be,returnPromise:true})}else{let e=E(r.target);if(r.target&&!e||r.source&&!e&&!E(r.source)){e=be}return fe(t,n,E(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return fe(t,n,null,null,{returnPromise:true})}}function Ln(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function kn(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Y.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",se({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n of Object.keys(e)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function Mn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function fe(t,n,r,o,i,P){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ee().body}const M=i.handler||Vn;const F=i.select||null;if(!ie(r)){ne(s);return e}const c=i.targetOverride||le(Se(r));if(c==null||c==be){ue(r,"htmx:targetError",{target:te(r,"hx-target")});ne(l);return e}let u=re(r);const a=u.lastButtonClicked;if(a){const A=Q(a,"formaction");if(A!=null){n=A}const N=Q(a,"formmethod");if(N!=null){if(he.includes(N.toLowerCase())){t=N}else{ne(s);return e}}}const f=te(r,"hx-confirm");if(P===undefined){const K=function(e){return fe(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(ae(r,"htmx:confirm",G)===false){ne(s);return e}}let h=r;let d=te(r,"hx-sync");let p=null;let B=false;if(d){const I=d.split(":");const L=I[0].trim();if(L==="this"){h=we(r,"hx-sync")}else{h=le(ce(r,L))}d=(I[1]||"drop").trim();u=re(h);if(d==="drop"&&u.xhr&&u.abortable!==true){ne(s);return e}else if(d==="abort"){if(u.xhr){ne(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const k=re(o);if(k&&k.triggerSpec&&k.triggerSpec.queue){p=k.triggerSpec.queue}}if(p==null){p="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(p==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){fe(t,n,r,o,i)})}else if(p==="all"){u.queuedRequests.push(function(){fe(t,n,r,o,i)})}else if(p==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){fe(t,n,r,o,i)})}ne(s);return e}}const g=new XMLHttpRequest;u.xhr=g;u.abortable=B;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const X=te(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:c})){ne(s);m();return e}}if(f&&!P){if(!confirm(f)){ne(s);m();return e}}let x=yn(r,c,y);if(t!=="get"&&!wn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=se(x,i.headers)}const U=pn(r,t);let b=U.errors;const j=U.formData;if(i.values){dn(j,Dn(i.values))}const V=Dn(qn(r,o));const v=dn(j,V);let w=xn(v,r);if(Y.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",Q(c,"id")||"true")}if(n==null||n===""){n=location.href}const S=On(r,"hx-request");const $=re(r).boosted;let E=Y.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:Mn(w),unfilteredFormData:v,unfilteredParameters:Mn(v),headers:x,elt:r,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Y.config.withCredentials,timeout:i.timeout||S.timeout||Y.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){ne(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);ne(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let T=n;if(E){T=z;const Z=!w.keys().next().done;if(Z){if(T.indexOf("?")<0){T+="?"}else{T+="&"}T+=mn(w);if(O){T+="#"+O}}}if(!kn(r,T,C)){ue(r,"htmx:invalidPath",C);ne(l);m();return e}g.open(t.toUpperCase(),T,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const D of Object.keys(x)){An(g,D,x[D])}}const H={xhr:g,target:c,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:T,responsePath:null,anchor:O}};g.onload=function(){try{const t=Ln(r);H.pathInfo.responsePath=Nn(g);M(r,H);if(H.keepIndicators!==true){on(R,q)}ae(r,"htmx:afterRequest",H);ae(r,"htmx:afterOnLoad",H);if(!ie(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(ie(n)){e=n}}if(e){ae(e,"htmx:afterRequest",H);ae(e,"htmx:afterOnLoad",H)}}ne(s)}catch(e){ue(r,"htmx:onLoadError",se({error:e},H));throw e}finally{m()}};g.onerror=function(){on(R,q);ue(r,"htmx:afterRequest",H);ue(r,"htmx:sendError",H);ne(l);m()};g.onabort=function(){on(R,q);ue(r,"htmx:afterRequest",H);ue(r,"htmx:sendAbort",H);ne(l);m()};g.ontimeout=function(){on(R,q);ue(r,"htmx:afterRequest",H);ue(r,"htmx:timeout",H);ne(l);m()};if(!ae(r,"htmx:beforeRequest",H)){ne(s);m();return e}var R=nn(r);var q=rn(r);oe(["loadstart","loadend","progress","abort"],function(t){oe([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",H);const J=E?null:Sn(g,r,w);g.send(J);return e}function Fn(e,t){const n=t.xhr;let r=null;let o=null;if(H(n,"HX-Push")){r=n.getResponseHeader("HX-Push");o="push"}else if(H(n,"HX-Push-Url")){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(H(n,"HX-Replace-Url")){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=t.etc.push||te(e,"hx-push-url");let c=t.etc.replace||te(e,"hx-replace-url");if(c==="false")c=null;const u=re(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function Bn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Xn(e){for(var t=0;t`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"")}}function Yn(){const e=ee().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Qn(){const e=Yn();if(e){Y.config=se(Y.config,e)}}Wn(function(){Qn();Zn();let e=ee().body;Bt(e);const t=ee().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=re(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){tn();oe(t,function(e){ae(e,"htmx:restored",{document:ee(),triggerEvent:ae})})}else{if(n){n(e)}}};b().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Y}(); \ No newline at end of file diff --git a/scripts/backend-mvc/src/main/resources/templates/edit.html b/scripts/backend-mvc/src/main/resources/templates/edit.html new file mode 100644 index 00000000000..8cb041fb685 --- /dev/null +++ b/scripts/backend-mvc/src/main/resources/templates/edit.html @@ -0,0 +1,12 @@ + + + + +
    +

    Add product

    +
    +

    +

    + +
    Cancel
    +
    diff --git a/scripts/backend-mvc/src/main/resources/templates/layout.html b/scripts/backend-mvc/src/main/resources/templates/layout.html new file mode 100644 index 00000000000..8c36d8c0476 --- /dev/null +++ b/scripts/backend-mvc/src/main/resources/templates/layout.html @@ -0,0 +1,2 @@ + +Catalog
    CatalogCompiled HTML ยท Java + htmx
    diff --git a/scripts/backend-mvc/src/main/resources/templates/products.html b/scripts/backend-mvc/src/main/resources/templates/products.html new file mode 100644 index 00000000000..3b1364b758c --- /dev/null +++ b/scripts/backend-mvc/src/main/resources/templates/products.html @@ -0,0 +1,11 @@ + + +
    +
    +

    Products

    A small inventory, served directly from Java.

    Add product
    + + + +
    NameQuantityStatusActions
    Notebook12ActiveEdit +

    No products yet. Add your first one.

    +
    diff --git a/scripts/copyright-header-exclusions.txt b/scripts/copyright-header-exclusions.txt index 46c892d331e..e5024951365 100644 --- a/scripts/copyright-header-exclusions.txt +++ b/scripts/copyright-header-exclusions.txt @@ -366,3 +366,4 @@ vm/ByteCodeTranslator/src/com/codename1/tools/translator/classfile/tree/TableSwi vm/ByteCodeTranslator/src/com/codename1/tools/translator/classfile/tree/TryCatchBlockNode.java | Rewrite of ASM org.objectweb.asm.tree.TryCatchBlockNode for ParparVM, retaining ASM's BSD-3-Clause notice (INRIA, France Telecom) vm/ByteCodeTranslator/src/com/codename1/tools/translator/classfile/tree/TypeInsnNode.java | Rewrite of ASM org.objectweb.asm.tree.TypeInsnNode for ParparVM, retaining ASM's BSD-3-Clause notice (INRIA, France Telecom) vm/ByteCodeTranslator/src/com/codename1/tools/translator/classfile/tree/VarInsnNode.java | Rewrite of ASM org.objectweb.asm.tree.VarInsnNode for ParparVM, retaining ASM's BSD-3-Clause notice (INRIA, France Telecom) +scripts/backend-mvc/src/main/resources/static/htmx-2.0.11.min.js | Unmodified htmx 2.0.11 distribution, Zero-Clause BSD license in scripts/backend-mvc/HTMX-LICENSE.txt diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/androidcompat/AndroidCompatWidgetsScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/androidcompat/AndroidCompatWidgetsScreenshotTest.java index b8017de3614..1a663e8ee9e 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/androidcompat/AndroidCompatWidgetsScreenshotTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/androidcompat/AndroidCompatWidgetsScreenshotTest.java @@ -23,6 +23,8 @@ package com.codenameone.examples.hellocodenameone.tests.androidcompat; import android.app.Activity; +import android.view.View; +import android.view.ViewGroup; import com.example.droid.WidgetsActivity; /// The framework widgets: compound buttons, a switch, progress, seek and rating bars, in a scroll view. @@ -33,6 +35,20 @@ public AndroidCompatWidgetsScreenshotTest() { @Override protected void prepare(Activity activity) { - hideIndeterminateProgress(activity.getWindow().getDecorView()); + View root = activity.getWindow().getDecorView(); + hideIndeterminateProgress(root); + hideScrollIndicators(root); + } + + // Scroll indicator fading depends on frame timing; this fixture captures widget appearance. + private static void hideScrollIndicators(View root) { + root.setVerticalScrollBarEnabled(false); + root.setHorizontalScrollBarEnabled(false); + if (root instanceof ViewGroup) { + ViewGroup group = (ViewGroup) root; + for (int i = 0; i < group.getChildCount(); i++) { + hideScrollIndicators(group.getChildAt(i)); + } + } } } diff --git a/vm/backend/src/com/codename1/backend/annotations/Controller.java b/vm/backend/src/com/codename1/backend/annotations/Controller.java new file mode 100644 index 00000000000..927a5503e43 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Controller.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; +import java.lang.annotation.*; +/// Build-time MVC declaration. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface Controller { } diff --git a/vm/backend/src/com/codename1/backend/annotations/ModelAttribute.java b/vm/backend/src/com/codename1/backend/annotations/ModelAttribute.java new file mode 100644 index 00000000000..85cb6a6768c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ModelAttribute.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; +import java.lang.annotation.*; +/// Build-time MVC declaration. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.PARAMETER) +public @interface ModelAttribute { String value(); } diff --git a/vm/backend/src/com/codename1/backend/annotations/ResponseBody.java b/vm/backend/src/com/codename1/backend/annotations/ResponseBody.java new file mode 100644 index 00000000000..78df7846236 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ResponseBody.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; +import java.lang.annotation.*; +/// Build-time MVC declaration. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface ResponseBody { } diff --git a/vm/backend/src/com/codename1/backend/mvc/BindingResult.java b/vm/backend/src/com/codename1/backend/mvc/BindingResult.java new file mode 100644 index 00000000000..07c4c5cccdd --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mvc/BindingResult.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.mvc; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Conversion errors, submitted values, and application validation for one form. +public final class BindingResult { + private final Map values = new LinkedHashMap(); + private final Map> errors = new LinkedHashMap>(); + + public void submitted(String field, String value) { + values.put(field, value); + } + + public Object fieldValue(String field, Object fallback) { + return values.containsKey(field) ? values.get(field) : fallback; + } + + public void reject(String message) { + rejectValue("", message); + } + + public void rejectValue(String field, String message) { + List list = errors.get(field); + if (list == null) { + list = new ArrayList(); + errors.put(field, list); + } + list.add(message); + } + + public boolean hasErrors() { + return !errors.isEmpty(); + } + + public List getFieldErrors(String field) { + List list = errors.get(field); + return list == null ? Collections.emptyList() : Collections.unmodifiableList(list); + } + + public String messages(String field) { + StringBuilder text = new StringBuilder(); + for (Map.Entry> entry : errors.entrySet()) { + if (!"*".equals(field) && !entry.getKey().equals(field)) { + continue; + } + for (String message : entry.getValue()) { + if (text.length() > 0) { + text.append("; "); + } + text.append(message); + } + } + return text.toString(); + } +} diff --git a/vm/backend/src/com/codename1/backend/mvc/FormValues.java b/vm/backend/src/com/codename1/backend/mvc/FormValues.java new file mode 100644 index 00000000000..1a48cccea8c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mvc/FormValues.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.mvc; + +/// Strict scalar form conversion used by generated binders. +public final class FormValues { + private FormValues() {} + + public static Boolean bool(String raw) { + if ("true".equalsIgnoreCase(raw) || "on".equalsIgnoreCase(raw) || "1".equals(raw)) { + return Boolean.TRUE; + } + if ("false".equalsIgnoreCase(raw) || "off".equalsIgnoreCase(raw) || "0".equals(raw)) { + return Boolean.FALSE; + } + throw new IllegalArgumentException("Invalid boolean"); + } + + public static Character character(String raw) { + if (raw == null || raw.length() != 1) { + throw new IllegalArgumentException("Invalid character"); + } + return Character.valueOf(raw.charAt(0)); + } +} diff --git a/vm/backend/src/com/codename1/backend/mvc/Html.java b/vm/backend/src/com/codename1/backend/mvc/Html.java new file mode 100644 index 00000000000..fb606816887 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mvc/Html.java @@ -0,0 +1,386 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.mvc; + +import com.codename1.backend.ByteSink; +import com.codename1.backend.HttpServer; +import com.codename1.backend.Json; +import com.codename1.backend.security.CsrfToken; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// Primitives called by generated views; no template evaluation occurs here. +public final class Html { + private Html() {} + + public static byte[] utf8(String text) { + try { + return text.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException ex) { + throw new IllegalStateException(ex); + } + } + + public static byte[] bytes(ByteSink out) { + byte[] result = new byte[out.length()]; + System.arraycopy(out.bytes(), 0, result, 0, result.length); + return result; + } + + public static String string(Object value) { + return value == null ? "" : String.valueOf(value); + } + + public static boolean truth(Object value) { + if (value == null || Boolean.FALSE.equals(value)) { + return false; + } + if (value instanceof Number) { + return ((Number) value).doubleValue() != 0.0; + } + if (value instanceof Character) { + return ((Character) value).charValue() != 0; + } + if (value instanceof String) { + String text = ((String) value).trim(); + return !"false".equalsIgnoreCase(text) + && !"no".equalsIgnoreCase(text) + && !"off".equalsIgnoreCase(text); + } + return true; + } + + public static boolean equal(Object a, Object b) { + return a == null ? b == null : a.equals(b); + } + + public static void text(ByteSink out, Object value) { + String s = string(value); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '&') { + out.putAscii("&"); + } else if (c == '<') { + out.putAscii("<"); + } else if (c == '>') { + out.putAscii(">"); + } else if (c == '"') { + out.putAscii("""); + } else if (c == '\'') { + out.putAscii("'"); + } else if (Character.isHighSurrogate(c) + && i + 1 < s.length() + && Character.isLowSurrogate(s.charAt(i + 1))) { + out.putCodePoint(Character.toCodePoint(c, s.charAt(++i))); + } else { + out.putCodePoint(Character.isSurrogate(c) ? 0xfffd : c); + } + } + } + + public static void attribute(ByteSink out, String name, Object value) { + if (value == null) { + return; + } + name = asciiLower(name); + if (name.startsWith("data-hx-")) { + name = name.substring(5); + } + if ("href".equals(name) + || "xlink:href".equals(name) + || "src".equals(name) + || "data".equals(name) + || "action".equals(name) + || "formaction".equals(name) + || "hx-get".equals(name) + || "hx-post".equals(name) + || "hx-put".equals(name) + || "hx-patch".equals(name) + || "hx-delete".equals(name)) { + safeUrl(string(value)); + } + out.put(' '); + out.putAscii(name); + out.putAscii("=\""); + text(out, value); + out.put('"'); + } + + /// Snapshot an attribute's textual value before it is checked and rendered. + public static String attributeValue(Object value) { + return value == null ? null : string(value); + } + + /// A submitter can belong to a form outside this template or fragment. + public static String submitMethod(Model model, String value) { + if (value != null + && model.getAttribute("_csrf") != null + && !"post".equalsIgnoreCase(value) + && !"dialog".equalsIgnoreCase(value)) { + throw new IllegalArgumentException( + "GET submit-method overrides are not supported with CSRF tokens; use a separate" + + " GET form"); + } + return value; + } + + /// Form binding supports the two structured HTML form encodings. + public static String formEncoding(String value) { + if (value != null + && value.length() > 0 + && !"application/x-www-form-urlencoded".equalsIgnoreCase(value) + && !"multipart/form-data".equalsIgnoreCase(value)) { + throw new IllegalArgumentException("Unsupported form encoding: " + value); + } + return value; + } + + /// Preserve parameter filtering while keeping CSRF tokens out of htmx GET and DELETE URLs. + public static String htmxParameters(Model model, boolean urlParameters, String parameters) { + String filter = + parameters == null || parameters.length() == 0 || "unset".equals(parameters) + ? "*" + : parameters; + CsrfToken token = (CsrfToken) model.getAttribute("_csrf"); + if (!urlParameters || token == null || "none".equals(filter)) { + return filter; + } + String name = token.getParameterName(); + // htmx's comma-separated syntax cannot represent these names safely. + if (name.indexOf(',') >= 0 || !name.equals(name.trim())) { + return "none"; + } + if ("*".equals(filter)) { + return "not " + name; + } + boolean exclude = filter.startsWith("not "); + StringBuilder result = new StringBuilder(); + int start = exclude ? 4 : 0; + while (start <= filter.length()) { + int end = filter.indexOf(',', start); + if (end < 0) { + end = filter.length(); + } + String candidate = filter.substring(start, end).trim(); + start = end + 1; + if (candidate.equals(name)) { + if (exclude) { + return filter; + } + continue; + } + if (result.length() > 0) { + result.append(','); + } + result.append(candidate); + } + if (exclude) { + return "not " + result + (result.length() == 0 ? "" : ",") + name; + } + return result.length() == 0 ? "none" : result.toString(); + } + + /// htmx headers work for standalone controls and methods that serialize fields in URLs. + public static String htmxHeaders(Model model, boolean mutation, String headers) { + CsrfToken token = (CsrfToken) model.getAttribute("_csrf"); + if (!mutation || token == null) { + return headers; + } + Map merged = new LinkedHashMap(); + if (headers != null && !headers.trim().isEmpty() && !"unset".equals(headers)) { + try { + Map authored = Json.parseObject(headers); + for (Map.Entry entry : authored.entrySet()) { + String name = String.valueOf(entry.getKey()); + if (!name.equalsIgnoreCase(token.getHeaderName())) { + merged.put(name, entry.getValue()); + } + } + } catch (java.io.IOException error) { + throw new IllegalArgumentException( + "Mutating htmx controls require JSON hx-headers with CSRF protection", + error); + } + } + merged.put(token.getHeaderName(), token.getToken()); + return Json.write(merged); + } + + public static void booleanAttribute(ByteSink out, String name, Object value) { + if (truth(value)) { + attribute(out, name, name); + } + } + + public static String urlPart(Object value) { + byte[] bytes = utf8(string(value)); + StringBuilder out = new StringBuilder(); + String hex = "0123456789ABCDEF"; + for (byte b : bytes) { + int c = b & 255; + if ((c >= 'a' && c <= 'z') + || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') + || c == '-' + || c == '_' + || c == '.' + || c == '~') { + out.append((char) c); + } else { + out.append('%').append(hex.charAt(c >> 4)).append(hex.charAt(c & 15)); + } + } + return out.toString(); + } + + public static Object badModel(String message) { + throw new IllegalStateException(message); + } + + public static Object require(Model model, String name, String view) { + if (!model.containsAttribute(name)) { + throw new IllegalStateException("Missing model '" + name + "' for " + view); + } + return model.getAttribute(name); + } + + public static Object field(Model model, String form, String name, Object fallback) { + BindingResult result = (BindingResult) model.getAttribute("BindingResult." + form); + return result == null ? fallback : result.fieldValue(name, fallback); + } + + public static String errors(Model model, String form, String name) { + BindingResult result = (BindingResult) model.getAttribute("BindingResult." + form); + return result == null ? "" : result.messages(name); + } + + /// The browser derives an option's implicit value by collapsing ASCII whitespace. + public static String optionValue(Object value) { + String text = string(value); + StringBuilder out = new StringBuilder(); + boolean space = false; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f') { + space = out.length() > 0; + } else { + if (space) { + out.append(' '); + space = false; + } + out.append(c); + } + } + return out.toString(); + } + + public static boolean checked(Object value, Object candidate) { + if ("true".equalsIgnoreCase(string(candidate)) + && ("on".equalsIgnoreCase(string(value)) || "1".equals(string(value)))) { + return true; + } + return string(value).equals(string(candidate)); + } + + public static Model model(HttpServer.Request request) { + return new Model(request); + } + + public static void csrf(ByteSink out, Model model) { + csrf(out, model, null); + } + + public static void csrf(ByteSink out, Model model, Object formId) { + CsrfToken token = (CsrfToken) model.getAttribute("_csrf"); + if (token == null) { + return; + } + out.putAscii("'); + } + + private static String asciiLower(String value) { + // HTML names and URI schemes are ASCII; don't depend on the default locale. + char[] chars = null; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c >= 'A' && c <= 'Z') { + if (chars == null) { + chars = value.toCharArray(); + } + chars[i] = (char) (c + ('a' - 'A')); + } + } + return chars == null ? value : new String(chars); + } + + public static void safeUrl(String value) { + String lower = asciiLower(value.trim()); + for (int i = 0; i < lower.length(); i++) { + if (lower.charAt(i) < 32 || lower.charAt(i) == 127 || lower.charAt(i) == '\\') { + throw new IllegalArgumentException("Invalid URL"); + } + } + int colon = lower.indexOf(':'); + for (int i = 0; i < colon; i++) { + char c = lower.charAt(i); + if (c == '/' || c == '?' || c == '#') { + return; + } + } + if (colon >= 0 + && !lower.startsWith("https:") + && !lower.startsWith("http:") + && !lower.startsWith("mailto:") + && !lower.startsWith("tel:")) { + throw new IllegalArgumentException("Unsafe URL scheme"); + } + } + + public static void localLocation(String location) { + if (location == null + || !location.startsWith("/") + || location.startsWith("//") + || location.indexOf('\\') >= 0) { + throw new IllegalArgumentException("Redirect must be a local absolute path"); + } + for (int i = 0; i < location.length(); i++) { + if (location.charAt(i) <= 32 || location.charAt(i) == 127) { + throw new IllegalArgumentException("Invalid redirect"); + } + } + } + + public static HttpServer.Response redirect(HttpServer.Request request, String location) { + localLocation(location); + HttpServer.Response response = + Htmx.isRequest(request) + ? Htmx.redirect(location) + : HttpServer.Response.text(303, "").header("Location", location); + return response.header("Vary", "HX-Request, HX-History-Restore-Request"); + } +} diff --git a/vm/backend/src/com/codename1/backend/mvc/Htmx.java b/vm/backend/src/com/codename1/backend/mvc/Htmx.java new file mode 100644 index 00000000000..273cb6b389c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mvc/Htmx.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.mvc; + +import com.codename1.backend.HttpServer; + +/// Helpers for the htmx wire protocol. +public final class Htmx { + private Htmx() {} + + public static boolean isRequest(HttpServer.Request request) { + return "true".equalsIgnoreCase(request.getHeader("HX-Request")) + && !"true".equalsIgnoreCase(request.getHeader("HX-History-Restore-Request")); + } + + public static HttpServer.Response redirect(String location) { + Html.localLocation(location); + return HttpServer.Response.text(200, "").header("HX-Redirect", location); + } + + public static HttpServer.Response refresh() { + return HttpServer.Response.text(200, "").header("HX-Refresh", "true"); + } + + private static boolean letter(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + public static HttpServer.Response trigger(HttpServer.Response response, String event) { + if (event == null || event.length() == 0 || !letter(event.charAt(0))) { + throw new IllegalArgumentException("Invalid htmx event name"); + } + for (int i = 1; i < event.length(); i++) { + char c = event.charAt(i); + if (!letter(c) && !(c >= '0' && c <= '9') && "_:.-".indexOf(c) < 0) { + throw new IllegalArgumentException("Invalid htmx event name"); + } + } + return response.header("HX-Trigger", event); + } +} diff --git a/vm/backend/src/com/codename1/backend/mvc/IterationStatus.java b/vm/backend/src/com/codename1/backend/mvc/IterationStatus.java new file mode 100644 index 00000000000..b86d9550202 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mvc/IterationStatus.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.mvc; + +/// Thymeleaf-style iteration status for a compiled loop. +public final class IterationStatus { + private final int index; + private final int size; + + public IterationStatus(int index, int size) { + this.index = index; + this.size = size; + } + + public int getIndex() { + return index; + } + + public int getCount() { + return index + 1; + } + + public int getSize() { + return size; + } + + public boolean isFirst() { + return index == 0; + } + + public boolean isLast() { + return index == size - 1; + } + + public boolean isEven() { + // Thymeleaf uses one-based parity, matching CSS :nth-child(even/odd). + return getCount() % 2 == 0; + } + + public boolean isOdd() { + return !isEven(); + } +} diff --git a/vm/backend/src/com/codename1/backend/mvc/Model.java b/vm/backend/src/com/codename1/backend/mvc/Model.java new file mode 100644 index 00000000000..dfc4e832e0d --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mvc/Model.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.mvc; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.security.CsrfToken; +import com.codename1.impl.backend.security.SecuritySupport; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// Request-owned model shared by a controller and its compiled view. +public final class Model { + private final Map values = new LinkedHashMap(); + + public Model() {} + + Model(HttpServer.Request request) { + values.put("_csrf", new DeferredCsrf(request)); + } + + // Keep the deferred value in the map so merging models does not resolve it or lose it. + private static final class DeferredCsrf { + private final HttpServer.Request request; + private CsrfToken token; + private boolean resolved; + + DeferredCsrf(HttpServer.Request request) { + this.request = request; + } + + CsrfToken get() { + if (!resolved) { + token = SecuritySupport.csrfToken(request); + resolved = true; + } + return token; + } + } + + public Model addAttribute(String name, Object value) { + values.put(name, value); + return this; + } + + public Object getAttribute(String name) { + Object value = values.get(name); + return value instanceof DeferredCsrf ? ((DeferredCsrf) value).get() : value; + } + + public boolean containsAttribute(String name) { + return values.containsKey(name); + } + + public Model addAllAttributes(Model other) { + values.putAll(other.values); + return this; + } +} diff --git a/vm/backend/src/com/codename1/backend/mvc/ModelAndView.java b/vm/backend/src/com/codename1/backend/mvc/ModelAndView.java new file mode 100644 index 00000000000..cc88c6013b8 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mvc/ModelAndView.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.mvc; + +/// A compiled view name and the values supplied to it. +public final class ModelAndView { + private final String viewName; + private final Model model = new Model(); + + public ModelAndView(String viewName) { + this.viewName = viewName; + } + + public ModelAndView addObject(String name, Object value) { + model.addAttribute(name, value); + return this; + } + + public String getViewName() { + return viewName; + } + + public Model getModel() { + return model; + } +} diff --git a/vm/backend/src/com/codename1/backend/mvc/package-info.java b/vm/backend/src/com/codename1/backend/mvc/package-info.java new file mode 100644 index 00000000000..a8a086a9867 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mvc/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// Typed models, form binding results, and HTML/htmx helpers for build-time compiled views. +/// Renderers call these primitives directly; templates and expressions are compiled during the build. +package com.codename1.backend.mvc; diff --git a/vm/selfhost/perf-baseline/pr/5974.json b/vm/selfhost/perf-baseline/pr/5974.json new file mode 100644 index 00000000000..1807e02def2 --- /dev/null +++ b/vm/selfhost/perf-baseline/pr/5974.json @@ -0,0 +1,69 @@ +{ + "pr": 5974, + "rebaseline": { + "linux-x64@amd-epyc-9v45-96-core-processor": { + "translator": { + "all": { + "from": { + "memory": 0.414, + "time": 0.641, + "tolerance": { + "time": 0.2 + } + }, + "memory": 0.454, + "reason": "Recalibrate only translator RAM on EPYC 9V45 from unchanged native benchmark code: runs 38024416107 (bb9bdf3516), 38018068620 (0a74400bcf), and 37889557701 (b930a70c88), five rounds each. All native translator, JavaAPI, benchmark, and measurement sources match; only baseline records and a JavaScript-only bridge differ. Run-median RAM ratios are 0.480, 0.449, and 0.454. Preserve timing and existing tolerances.", + "runs": 3, + "time": 0.641, + "tolerance": { + "time": 0.2 + } + } + } + }, + "windows-x64@amd64-family-26-model-2-authenticamd": { + "stringBuilding": { + "all": { + "from": { + "memory": 0.223, + "time": 1.386, + "tolerance": { + "memory": 0.35, + "time": 0.2 + } + }, + "memory": 0.297, + "reason": "Recalibrate only stringBuilding RAM on AMD64 Family 26 Model 2 using runs 38024416103 (bb9bdf3516) and 37867674691 (ac6a6ea42c), five rounds each, with identical native translator, JavaAPI, benchmark, and measurement sources. ParparVM peak RAM remains about 80-85 MiB; JDK 25 varies between about 247, 300, and 480 MiB. Run-median ratios are 0.325 and 0.269. Preserve timing and existing tolerances.", + "runs": 2, + "time": 1.386, + "tolerance": { + "memory": 0.35, + "time": 0.2 + } + } + } + }, + "linux-x64@intel-xeon-platinum-8573c": { + "hello": { + "all": { + "memory": 0.876, + "runs": 2, + "time": 0.942, + "tolerance": { + "memory": 0.2, + "time": 0.5 + }, + "from": { + "memory": 0.732, + "time": 0.942, + "tolerance": { + "memory": 0.2, + "time": 0.5 + } + }, + "reason": "Recalibrate only hello RAM on Intel Xeon Platinum 8573C from runs 38060057999 (CI merge 77379c47) and 38063026105 (CI merge 531c1e44), five rounds each. Native translator, JavaAPI, benchmark and measurement sources are identical; the workflow difference only removes Xvfb from the separate common-build job. Run-median RAM ratios are 0.883 and 0.869, against the old 0.732 baseline. Preserve timing and existing tolerances." + } + } + } + } +}