From d255110b3539472ec7a522c4a91d52b0fec48021 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Tue, 21 Jul 2026 13:57:49 +0200 Subject: [PATCH 01/36] docs: add ADRs for JavaScript server extension points ADR-0001: per-type registrars over a shared base class (whiteboard-aligned) ADR-0002: first-class registry types (action, choicelist-initializer, node-validator) ADR-0003: typed TS registration wrappers with raw Java escape hatch ADR-0004: CSRF whitelisting stays the module author's responsibility ADR-0005: node validators bridged via a single Bean Validation bean on nt:base --- ...0001-javascript-server-extension-points.md | 63 +++++++++++++++++ docs/adr/0002-first-class-registry-types.md | 30 ++++++++ docs/adr/0003-typed-registration-wrappers.md | 38 +++++++++++ .../0004-csrf-whitelisting-for-js-actions.md | 42 ++++++++++++ ...alidators-single-bean-validation-bridge.md | 68 +++++++++++++++++++ docs/adr/README.md | 15 ++++ 6 files changed, 256 insertions(+) create mode 100644 docs/adr/0001-javascript-server-extension-points.md create mode 100644 docs/adr/0002-first-class-registry-types.md create mode 100644 docs/adr/0003-typed-registration-wrappers.md create mode 100644 docs/adr/0004-csrf-whitelisting-for-js-actions.md create mode 100644 docs/adr/0005-js-node-validators-single-bean-validation-bridge.md create mode 100644 docs/adr/README.md diff --git a/docs/adr/0001-javascript-server-extension-points.md b/docs/adr/0001-javascript-server-extension-points.md new file mode 100644 index 00000000..a4d9af4d --- /dev/null +++ b/docs/adr/0001-javascript-server-extension-points.md @@ -0,0 +1,63 @@ +# Bridge JavaScript-declared server extension points through per-type registrars + +* Status: accepted +* Date: 2026-07-21 + +## Context and Problem Statement + +JavaScript modules can only contribute views/templates (and untyped render filters) today. Every other Jahia extension point — choicelist initializers, server-side node validators, actions, etc. — requires a Java OSGi module. We want JavaScript modules to declare these extension points directly, with a mechanism that makes adding *future* extension points cheap. + +A proof of concept existed in the pre-rename engine ([npm-modules-engine#125](https://github.com/Jahia/npm-modules-engine/pull/125)): a single `ServicesRegistrar` reading registry entries of `type='service'` and dispatching to per-type mapper classes held in a static map. + +## Decision Drivers + +* Adding a new extension point later must be a small, local change. +* Bridges must survive GraalVM context pooling: contexts are recycled and version-invalidated on every module (un)deploy, so JS function handles cannot be cached. +* Jahia core already consumes `Action`, `ModuleChoiceListInitializer`, `RenderFilter`, … as OSGi services (whiteboard-tracked by core's `OSGIRegistry` and piped into `TemplatePackageRegistry`) — we should ride that supported surface rather than reach into core internals. +* Registrars need per-type Jahia service dependencies (for collision warnings, etc.) that are naturally expressed as Declarative Services references. + +## Considered Options + +1. **One `Registrar` component per extension type, over a shared abstract base class** (`AbstractServiceRegistrar`). +2. Single generic registrar with a static map of per-type mappers (the POC design). +3. Fully independent per-type registrars with duplicated bookkeeping (the pre-existing `RenderFilterRegistrar` pattern, copy-pasted). + +## Decision Outcome + +Chosen option: **1 — per-type registrar components over a shared base class.** + +* Each extension point is a `@Component(service = Registrar.class)` extending `AbstractServiceRegistrar`, which owns the generic flow: find registry entries for the bundle (`{type, bundleKey}`), build a bridge per entry (`createBridge`), publish it as an OSGi service, track per-bundle `ServiceRegistration`s, and unregister them on bundle stop — with per-entry error isolation. +* `JavascriptModuleListener` already discovers `Registrar` services dynamically (`@Reference(MULTIPLE, DYNAMIC, GREEDY)`) and replays already-started JS bundles to late-arriving registrars. A new extension point is therefore one new subclass — no central wiring to touch. +* Bridges **never cache JS function handles**. On every invocation they re-resolve the registry entry inside `GraalVMEngine.doWithContext(cp -> cp.getRegistry().get(type, key))`. If the entry is gone (module stopped mid-flight), they log and return a benign default instead of failing. +* Per-type DS components keep Jahia service dependencies (`@Reference`) local to the type that needs them, and a registrar that fails to activate does not take the others down. + +Note on Declarative Services inheritance: bnd does not process DS annotations on inherited members, so the base class holds plain `protected` fields and every concrete registrar declares its own `@Reference`/`@Activate` methods. + +### Whiteboard pattern alignment + +The mechanism is deliberately whiteboard-shaped at both OSGi seams: + +* **Registrars** are whiteboard participants: publish a `Registrar` service and `JavascriptModuleListener` picks it up. +* **Bridges** are whiteboard participants toward core: we publish plain `Action` / `ModuleChoiceListInitializer` / `RenderFilter` services and core's `OSGIRegistry` tracks them — the engine never calls core registration APIs for these. + +The JS-side `server.registry` is *not* a whiteboard — GraalVM code cannot publish OSGi services, and the registry is per-pooled-context. It acts as a staging registry that registrars mirror onto the OSGi whiteboard once per bundle start. (Node validators deviate from the whiteboard for correctness reasons — see [ADR-0005](0005-js-node-validators-single-bean-validation-bridge.md).) + +### Consequences + +* Good: new extension points are one subclass + one library wrapper; no dispatch table, no central registry of mappers. +* Good: unregistration and error isolation are written once, in the base class. +* Bad: one DS component per type (slightly more boilerplate than a static map) — accepted for testability and failure isolation. +* Neutral: every bridge invocation borrows a pooled GraalVM context (or reuses the current thread's); this is the same cost profile as the pre-existing render filters and views. + +## Pros and Cons of the Options + +### Option 2 — single registrar + static mapper map (POC) + +* Good: one component. +* Bad: per-type Jahia dependencies pile into one class or mappers do raw service lookups outside DS. +* Bad: static map is not injectable/mockable; a broken mapper risks the whole dispatch. + +### Option 3 — independent copy-pasted registrars + +* Good: no abstraction to design. +* Bad: per-bundle bookkeeping and error handling duplicated (and already inconsistent: the legacy `RenderFilterRegistrar` lacked null-guards); every future type pays the full cost again. diff --git a/docs/adr/0002-first-class-registry-types.md b/docs/adr/0002-first-class-registry-types.md new file mode 100644 index 00000000..c4f7b056 --- /dev/null +++ b/docs/adr/0002-first-class-registry-types.md @@ -0,0 +1,30 @@ +# Use first-class registry types for each extension point + +* Status: accepted +* Date: 2026-07-21 + +## Context and Problem Statement + +JS modules declare objects in the engine registry (`server.registry.add(type, key, ...)`). The registry namespaces entries by `type + "-" + key`. The POC ([npm-modules-engine#125](https://github.com/Jahia/npm-modules-engine/pull/125)) registered all extension points under a single umbrella `type: 'service'` with a `serviceType` property as a second-level discriminator. How should the new extension points be keyed? + +## Considered Options + +1. **First-class registry types**: `action`, `choicelist-initializer`, `node-validator` (kebab-case, like the existing `view`, `viewRenderer`, `render-filter`, `bundleInitializer`). +2. POC style: `type: 'service'` + `serviceType` discriminator. + +## Decision Outcome + +Chosen option: **1 — first-class registry types.** + +* The registry keys entries as `type-key`. With a shared `service` type, an action named `foo` and a choicelist initializer named `foo` collide and `Registry.add` throws. For actions and choicelists the key *is* the platform-visible name, so cross-kind collisions are a real hazard, not a theoretical one. +* Registrar discovery uses `Registry.find({type, bundleKey})` — exactly the pattern `ViewsRegistrar` and `RenderFilterRegistrar` already use. A second-level discriminator would add filtering logic for no benefit. +* It matches how the platform is described ("registry of views, of render filters, of actions") and the registry conventions developers already know from jContent. +* Nothing in the POC exploited the `service` umbrella; its own TODOs pointed at dedicated typed registration functions. + +The existing `render-filter` type string is kept as-is for backward compatibility with modules that call `server.registry.add('render-filter', ...)` directly. + +### Consequences + +* Good: no cross-kind key collisions; discovery stays a single exact-match filter. +* Good: each type can evolve its entry shape independently. +* Bad: one more type string to know per extension point — mitigated by the typed wrappers ([ADR-0003](0003-typed-registration-wrappers.md)), which make the type string an implementation detail most developers never see. diff --git a/docs/adr/0003-typed-registration-wrappers.md b/docs/adr/0003-typed-registration-wrappers.md new file mode 100644 index 00000000..eda8c325 --- /dev/null +++ b/docs/adr/0003-typed-registration-wrappers.md @@ -0,0 +1,38 @@ +# Idiomatic TypeScript registration wrappers with a raw Java escape hatch + +* Status: accepted +* Date: 2026-07-21 + +## Context and Problem Statement + +The POC passed raw Java objects (`HttpServletRequest`, `ExtendedPropertyDefinition`, `JCRSessionWrapper`, `Locale`, …) straight into JS callbacks and expected Java-shaped return values. That is fast to build but couples module code to Java APIs, is hard to type, and leaks polyglot conversion pitfalls (e.g. nested JS objects converted with `Value.as(Map.class)`) into user code. How should the developer-facing API look? + +## Decision Drivers + +* Developer experience consistent with `jahiaComponent`: one exported function, TypeScript-typed, registration as a module-init side effect. +* Advanced use cases must stay possible: the underlying Java objects carry capabilities we cannot re-expose exhaustively. +* Polyglot value conversion must be controlled in one place, not in every module. + +## Considered Options + +1. **Idiomatic TS-first signatures with a `java` escape hatch, adaptation done in the library (TS side).** +2. Raw Java objects everywhere (POC style). +3. Fully abstracted JS API with no Java access. + +## Decision Outcome + +Chosen option: **1.** + +* The library exports one registration function per extension point (`registerChoiceListInitializer`, `registerAction`, `registerNodeValidator`, `registerRenderFilter`), siblings of `jahiaComponent` in `javascript-modules-library/src/framework/`. +* Each wrapper stores an *adapter* function in the registry: the Java bridge always calls a stable, raw-shaped function; the TS adapter converts to/from the idiomatic shapes before invoking the user callback. **Java stays dumb and stable; the adaptation lives in TS**, where it is cheap to evolve and unit-test. +* Idiomatic context objects expose converted values (e.g. `locale` as a BCP-47 language tag via `Locale.toLanguageTag()`, parameters as `Record`) and keep the raw Java objects under a `java` property (or as documented raw fields such as the `JCRNodeWrapper` itself, which is already the library's public node surface). +* Structured return values that must cross the boundary as JSON (action results) are **pre-stringified with `JSON.stringify` in the adapter** and parsed with `new JSONObject(String)` on the Java side. This sidesteps polyglot deep-conversion issues with nested objects/arrays that broke the POC's `new JSONObject(value.as(Map.class))` approach. +* Java types referenced in public signatures are provided by the existing java-ts-bind generation; types not yet generated (`ExtendedPropertyDefinition`, `URLResolver`, `Locale.toLanguageTag`) are added to the bind configuration with narrow method whitelists. +* Handlers are synchronous in v1. Promise-returning handlers require explicit GraalJS promise resolution across the host boundary and are deferred. + +### Consequences + +* Good: typed, documented, discoverable API; conversion bugs are fixed once in the library. +* Good: no capability loss — the escape hatch keeps the full Java surface reachable. +* Bad: two representations of some values (idiomatic + raw) can confuse; mitigated by docs marking the `java` property as the escape hatch. +* Bad: the registry entry shape becomes a library↔engine contract that must be kept in sync (documented in both the wrapper and the bridge). diff --git a/docs/adr/0004-csrf-whitelisting-for-js-actions.md b/docs/adr/0004-csrf-whitelisting-for-js-actions.md new file mode 100644 index 00000000..6960965d --- /dev/null +++ b/docs/adr/0004-csrf-whitelisting-for-js-actions.md @@ -0,0 +1,42 @@ +# CSRF whitelisting of JavaScript actions is the module author's responsibility + +* Status: accepted +* Date: 2026-07-21 + +## Context and Problem Statement + +Actions are invoked via `..do`. Unsafe HTTP methods (POST/PUT/DELETE) on `.do` URLs are blocked by Jahia's CSRF guard module unless the URL pattern is whitelisted through its OSGi configuration factory (PID `org.jahia.modules.jahiacsrfguard`, property `whitelist = *..do`). Java module authors ship such a config file with their module today. What should the engine do for JS-declared actions? + +## Considered Options + +1. **Document-only**: the JS module ships its own `settings/configurations/org.jahia.modules.jahiacsrfguard-.cfg`, exactly like Java modules. +2. Engine auto-registers a ConfigAdmin factory instance covering each JS action's URL on deploy. +3. Opt-in flag on the action declaration that triggers auto-registration. + +## Decision Outcome + +Chosen option: **1 — document-only.** + +JS modules already ship OSGi configs: the bundle transformer maps `settings/**` into `META-INF/**`, and Jahia deploys `META-INF/configurations/*.cfg` on module install. So the recipe is one file in the module: + +```properties +# settings/configurations/org.jahia.modules.jahiacsrfguard-mymodule.cfg +whitelist = *.myAction.do +``` + +* Security posture: opting out of CSRF protection stays an **explicit, auditable, per-module act** that reviewers and operators can see in the module source and in the deployed configuration — never a side effect of declaring an action. +* Parity: identical mental model and operational behavior as Java modules. +* Zero engine code, zero new lifecycle to maintain. + +The main cost — a developer forgetting the file and getting an opaque 403 on POST — is mitigated by a prominent section in the actions guide and by the test module exercising the recipe end-to-end. + +### Rejected alternative: ConfigAdmin auto-registration (options 2 and 3) + +The engine would create/update a `jahiacsrfguard` factory configuration per JS bundle (marker property for ownership, deleted on undeploy). Rejected because: + +* **Silently weakens CSRF protection** (option 2): developers never see the opt-out happen; a compromised or careless module opens POST endpoints without any reviewable artifact. +* **Persistence hazards**: ConfigAdmin configurations survive crashes and uninstall-without-stop, requiring marker-based reconciliation logic to avoid orphaned whitelist entries. +* **Cluster semantics are unclear**: Jahia's configuration management synchronizes file-based configs; programmatically created factory instances may not propagate, or may fight with operator-managed `.cfg` files for the same factory. +* Undefined behavior when the CSRF-guard module is absent or disabled. + +Option 3 (explicit `csrfWhitelisted: true` flag) fixes the visibility objection but keeps all the lifecycle/cluster hazards; it can be revisited if the documented recipe proves to be a recurring support burden. diff --git a/docs/adr/0005-js-node-validators-single-bean-validation-bridge.md b/docs/adr/0005-js-node-validators-single-bean-validation-bridge.md new file mode 100644 index 00000000..3d00995a --- /dev/null +++ b/docs/adr/0005-js-node-validators-single-bean-validation-bridge.md @@ -0,0 +1,68 @@ +# Bridge JS node validators through a single Bean Validation bean registered for `nt:base` + +* Status: accepted +* Date: 2026-07-21 + +## Context and Problem Statement + +Jahia's server-side content validation is class-and-annotation based, not functional: + +* Validators are registered per node type in a **global** map (`JCRStoreService.addValidator(nodeType, Class)`) — one validator class per node type platform-wide, last registration wins, `removeValidator(nodeType)` removes unconditionally. +* The class must implement `JCRNodeValidator` and expose a public `(JCRNodeWrapper)` constructor. On every session save, core instantiates it for each changed node matching the map key (`node.isNodeType(key)`) and runs Bean Validation (Hibernate Validator via Spring's `LocalValidatorFactoryBean`) over it, in two phases driven by validation groups: `(Default, DefaultSkipOnImportGroup)` then — only if clean — `(AdvancedGroup, AdvancedSkipOnImportGroup)`; imports omit the SkipOnImport groups. +* Violations map to editor errors through the violation's property path: a path resolving to a property definition produces a field-level `PropertyConstraintViolationException` (Content Editor field error); a blank path produces a node-level `NodeConstraintViolationException`. + +JS validators are dynamic functions declared at module init. We need a bridge from this functional model onto the static class/annotation model, with correct field-level error mapping and import semantics. + +## Decision Drivers + +* **Correctness under multi-type matching**: core instantiates and validates the bean once *per matching map entry*. Naively registering one bridge class under each declared node type makes a node matching K entries produce K duplicate violation sets (Hibernate Validator's violation dedup compares `rootBean` by `equals()`, and fresh bridge instances are never equal). +* Never clobber Java-module validators: the global map is last-wins, and removal is by node type only. +* JS functions must be re-resolved from the pooled GraalVM context registry per invocation. +* Messages come from user code and become Hibernate Validator message *templates*. + +## Considered Options + +1. **One engine-owned bean class registered under the single sentinel key `nt:base`; all node-type matching done engine/JS-side.** +2. Register the bridge class under each JS-declared node type. +3. Generate a distinct annotated validator class per JS registration (bytecode generation). +4. Ride the `JCRNodeValidatorDefinition` bean path like Java modules. +5. JCR interceptors/listeners throwing on save. + +## Decision Outcome + +Chosen option: **1 — single bean under sentinel `nt:base`.** + +* `JSNodeValidator implements JCRNodeValidator` carries four repeatable class-level `@JSValidation(mode=…, groups=…)` constraint annotations — one per Jahia phase combination (default / default-skip-on-import / advanced / advanced-skip-on-import). The `mode` attribute tells the shared `ConstraintValidator` which phase invoked it (the Bean Validation API does not expose active groups to `isValid`). +* Since every node `isNodeType("nt:base")` and the map holds exactly one JS entry, core instantiates the bean **exactly once per changed node per save** — every JS validator runs exactly once per phase, by construction. No dedup logic exists because none is needed. +* The `ConstraintValidator` obtains the engine registrar via OSGi lookup (`BundleUtils.getOsgiService`, the repo's established pattern; `null` → no-op) and dispatches: a fast volatile snapshot gate (`Mode → Set`, checked with `node.isNodeType`) avoids entering GraalVM for unaffected nodes; matching entries are then re-resolved from the live context registry and executed inside `doWithContext`. +* Violations are built programmatically: `disableDefaultConstraintViolation()` + `buildConstraintViolationWithTemplate(escapedMessage)`, with `.addPropertyNode(propertyName)` for field-level errors or none for node-level (blank path). The annotation deliberately has **no `propertyName()` attribute**, so core falls back to the per-violation property path. +* **Message escaping**: user messages are escaped (`\` → `\\`, `{` → `\{`, `}` → `\}`, `$` → `\$`, in that order) before becoming templates. On the platform's Hibernate Validator 6.2.0.Final, EL is off for custom violations, but `{…}` interpolation is active and an unbalanced brace throws; escaping `$` is defense-in-depth against future EL-default changes. Messages are therefore literals — resource-bundle template syntax does not apply; localization happens in the JS callback. +* **Lifecycle**: the registrar ref-counts declared validators across JS bundles; it calls `addValidator("nt:base", JSNodeValidator.class)` on 0→1 and `removeValidator("nt:base")` on 1→0 **only after verifying the registered constructor's declaring class is ours** (never delete a foreign validator; WARN in both collision directions). +* **Error policy**: a *throwing* JS validator fails the save with a generic node-level violation (fail-closed, mirroring Java validator behavior; loud ERROR log with the validator key). A *malformed returned violation* (missing/non-string message) is logged and skipped — a shape bug must not brick every content save on the platform. + +### Consequences + +* Good: exactly-once execution semantics; no interference with Java-module validators; zero overhead when no JS validator is registered (the bridge is not in the map at all). +* Neutral: while any JS validator exists, every changed node pays one reflective constructor + up to four gated `isValid` calls (no GraalVM entry unless a declared type matches) — cheaper than core's own per-node mandatory-property loop. +* Neutral/documented: a failing default-phase JS validator suppresses the advanced phase for *all* JS validators on that node (per-bean group orchestration — same behavior as a single Java validator class). +* Documented platform caveats: violations on i18n properties are silently dropped by core when the session locale is null; never call `session.save()` inside a validator. +* This is a deliberate deviation from the whiteboard pattern of [ADR-0001](0001-javascript-server-extension-points.md): core's validator consumption is a keyed map with unconditional removal, so precise ref-counted lifecycle control matters more than whiteboard purity here. + +## Pros and Cons of the Options + +### Option 2 — register under each declared node type + +* Bad: K-fold duplicate violations for nodes matching several entries; dedup would hinge on Hibernate-Validator-internal `equals` semantics. +* Bad: last-wins clobbering of Java validators on common node types; our removal could delete theirs. + +### Option 3 — bytecode generation per registration + +* Bad: ASM/ByteBuddy dependency, per-redeploy classloader and validator-metadata leaks, and it *still* ends in the same one-class-per-node-type map with the same collision hazards. + +### Option 4 — `JCRNodeValidatorDefinition` path + +* Bad: designed for module Spring contexts, which JS modules do not have; read once at bean (un)registration so dynamic add/remove per JS deploy does not propagate; inherits the same map-collision and duplicate-instantiation issues. + +### Option 5 — JCR interceptors/listeners + +* Bad: wrong lifecycle (no validation phases or import semantics), and no `CompositeConstraintViolationException` integration — field-level Content Editor errors are lost. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..abe63a8d --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,15 @@ +# Architecture Decision Records + +This directory contains the Architecture Decision Records (ADRs) for the JavaScript Modules project, in [MADR](https://adr.github.io/madr/) style. An ADR captures a single architecturally significant decision: its context, the options considered, and the consequences we accept. + +ADRs are numbered in the order they were accepted and are never rewritten once accepted — a superseding decision gets a new ADR that links back. + +## Index + +| ADR | Title | Status | +|-----|-------|--------| +| [0001](0001-javascript-server-extension-points.md) | Bridge JavaScript-declared server extension points through per-type registrars | accepted | +| [0002](0002-first-class-registry-types.md) | Use first-class registry types for each extension point | accepted | +| [0003](0003-typed-registration-wrappers.md) | Idiomatic TypeScript registration wrappers with a raw Java escape hatch | accepted | +| [0004](0004-csrf-whitelisting-for-js-actions.md) | CSRF whitelisting of JavaScript actions is the module author's responsibility | accepted | +| [0005](0005-js-node-validators-single-bean-validation-bridge.md) | Bridge JS node validators through a single Bean Validation bean registered for `nt:base` | accepted | From 7b3ffd929c16b5f5889665a79db42ab1a0d68921 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Tue, 21 Jul 2026 14:05:55 +0200 Subject: [PATCH 02/36] refactor: introduce AbstractServiceRegistrar shared base for JS extension bridges - New AbstractServiceRegistrar: per-bundle registry-entry discovery, bridge creation, OSGi service publication and tracked unregistration with per-entry error isolation (ADR-0001). - RenderFilterRegistrar refactored onto the base class. Two fixes folded in: the registry entry's priority is no longer overwritten to 0 after construction (parsed as float), and execute/prepare now null-guard both the registry entry (module stopped mid-flight) and null/undefined JS results. - mockito-core + graalvm js added at test scope. --- javascript-modules-engine-java/pom.xml | 12 ++ .../registrars/AbstractServiceRegistrar.java | 125 +++++++++++++ .../registrars/RenderFilterRegistrar.java | 74 ++++---- .../AbstractServiceRegistrarTest.java | 165 ++++++++++++++++++ 4 files changed, 336 insertions(+), 40 deletions(-) create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrar.java create mode 100644 javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrarTest.java diff --git a/javascript-modules-engine-java/pom.xml b/javascript-modules-engine-java/pom.xml index 6727ba77..c01d953d 100644 --- a/javascript-modules-engine-java/pom.xml +++ b/javascript-modules-engine-java/pom.xml @@ -208,6 +208,18 @@ JUnitParams test + + org.mockito + mockito-core + 4.11.0 + test + + + + org.graalvm.js + js + test + diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrar.java new file mode 100644 index 00000000..a972b3e4 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrar.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars; + +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collection; +import java.util.Dictionary; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Base class for registrars that expose JavaScript registry entries of a given type as OSGi services + * implementing a Jahia extension interface. + * + *

For each bundle, {@link #register(Bundle)} finds the registry entries matching the registrar's type, + * wraps each of them in a bridge built by {@link #createBridge(Map)} and publishes the bridge as an OSGi + * service of the registrar's service class. Registrations are tracked per bundle and released in + * {@link #unregister(Bundle)}. A failure on one entry never prevents the other entries from being processed. + * + *

Bridges must never capture JS function handles: GraalVM contexts are pooled and invalidated on every + * module (un)deploy, so a bridge must re-resolve its registry entry inside + * {@link GraalVMEngine#doWithContext} on every invocation. + * + *

Note that Declarative Services annotations are not processed on inherited members, so concrete + * subclasses must declare their own {@code @Component}, {@code @Reference} and {@code @Activate} members + * and assign the {@link #graalVMEngine} and {@link #bundleContext} fields. + */ +public abstract class AbstractServiceRegistrar implements Registrar { + + private static final Logger logger = LoggerFactory.getLogger(AbstractServiceRegistrar.class); + + private final Class serviceClass; + private final String registryType; + private final Map>> registrations = new ConcurrentHashMap<>(); + + protected GraalVMEngine graalVMEngine; + protected BundleContext bundleContext; + + protected AbstractServiceRegistrar(Class serviceClass, String registryType) { + this.serviceClass = serviceClass; + this.registryType = registryType; + } + + public String getRegistryType() { + return registryType; + } + + /** + * Builds the OSGi service bridge for a single registry entry. The returned object is published as a + * service of the registrar's service class. + */ + protected abstract S createBridge(Map registryEntry); + + /** + * Hook invoked before a bridge is created and registered, e.g. to emit key-collision warnings. + */ + protected void beforeRegister(Bundle bundle, Map registryEntry) { + // no-op by default + } + + /** + * Hook providing the OSGi service properties for a registry entry. + */ + protected Dictionary getServiceProperties(Map registryEntry) { + return new Hashtable<>(); + } + + @Override + public void register(Bundle bundle) { + List> entries = graalVMEngine.doWithContext(contextProvider -> { + Map filter = new HashMap<>(); + filter.put("type", registryType); + filter.put("bundleKey", bundle.getSymbolicName()); + return contextProvider.getRegistry().find(filter); + }); + + Collection> set = registrations.computeIfAbsent(bundle, b -> ConcurrentHashMap.newKeySet()); + for (Map entry : entries) { + try { + beforeRegister(bundle, entry); + set.add(bundleContext.registerService(serviceClass, createBridge(entry), getServiceProperties(entry))); + } catch (Exception e) { + logger.error("Unable to register {} '{}' from bundle {}", registryType, entry.get("key"), + bundle.getSymbolicName(), e); + } + } + } + + @Override + public void unregister(Bundle bundle) { + Collection> set = registrations.remove(bundle); + if (set != null) { + for (ServiceRegistration registration : set) { + try { + registration.unregister(); + } catch (Exception e) { + logger.warn("Error unregistering a {} service of bundle {}", registryType, + bundle.getSymbolicName(), e); + } + } + } + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/RenderFilterRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/RenderFilterRegistrar.java index 6a522109..f5b1c983 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/RenderFilterRegistrar.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/RenderFilterRegistrar.java @@ -24,25 +24,26 @@ import org.jahia.services.render.filter.AbstractFilter; import org.jahia.services.render.filter.RenderChain; import org.jahia.services.render.filter.RenderFilter; -import org.osgi.framework.Bundle; import org.osgi.framework.BundleContext; -import org.osgi.framework.ServiceRegistration; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; import org.osgi.service.component.annotations.ReferenceCardinality; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import java.util.*; +import java.util.Map; @Component(service = Registrar.class, immediate = true) -public class RenderFilterRegistrar implements Registrar { +public class RenderFilterRegistrar extends AbstractServiceRegistrar { - private RenderService renderService; - private BundleContext bundleContext; + public static final String REGISTRY_TYPE = "render-filter"; - private GraalVMEngine graalVMEngine; + private RenderService renderService; - private final Map>> registrations = new HashMap<>(); + public RenderFilterRegistrar() { + super(RenderFilter.class, REGISTRY_TYPE); + } @Reference public void setRenderService(RenderService renderService) { @@ -60,36 +61,16 @@ public void activate(BundleContext bundleContext) { } @Override - public void register(Bundle bundle) { - List> renderFilters = graalVMEngine.doWithContext(contextProvider -> { - Map filter = new HashMap<>(); - filter.put("type", "render-filter"); - filter.put("bundleKey", bundle.getSymbolicName()); - return contextProvider.getRegistry().find(filter); - }); - - Set> set = new HashSet<>(); - registrations.put(bundle, set); - for (Map renderFilter : renderFilters) { - RenderFilterBridge renderFilterImpl = new RenderFilterBridge(renderFilter, graalVMEngine); - renderFilterImpl.setRenderService(renderService); - renderFilterImpl.setPriority(0); - - set.add(bundleContext.registerService(RenderFilter.class, renderFilterImpl, new Hashtable<>())); - } - } - - @Override - public void unregister(Bundle bundle) { - Collection> set = registrations.remove(bundle); - if (set != null) { - for (ServiceRegistration registration : set) { - registration.unregister(); - } - } + protected RenderFilter createBridge(Map registryEntry) { + RenderFilterBridge bridge = new RenderFilterBridge(registryEntry, graalVMEngine); + bridge.setRenderService(renderService); + return bridge; } public static class RenderFilterBridge extends AbstractFilter { + + private static final Logger logger = LoggerFactory.getLogger(RenderFilterBridge.class); + private final GraalVMEngine engine; private final String key; @@ -97,7 +78,9 @@ public RenderFilterBridge(Map value, GraalVMEngine engine) { this.engine = engine; this.key = (String) value.get("key"); if (value.containsKey("priority")) { - setPriority(Integer.parseInt(value.get("priority").toString())); + setPriority(Float.parseFloat(value.get("priority").toString())); + } else { + setPriority(0); } if (value.containsKey("description")) { setDescription(value.get("description").toString()); @@ -120,21 +103,32 @@ public RenderFilterBridge(Map value, GraalVMEngine engine) { } @Override - public String execute(String s, RenderContext renderContext, Resource resource, RenderChain renderChain) throws Exception { + public String execute(String previousOut, RenderContext renderContext, Resource resource, RenderChain renderChain) throws Exception { return engine.doWithContext(contextProvider -> { - return Value.asValue(getJsFilter(contextProvider).get("execute")).execute(s, renderContext, resource, renderChain).asString(); + Map jsFilter = getJsFilter(contextProvider); + if (jsFilter == null || jsFilter.get("execute") == null) { + logger.warn("JS render filter '{}' is no longer available in the registry, skipping execute", key); + return previousOut; + } + Value result = Value.asValue(jsFilter.get("execute")).execute(previousOut, renderContext, resource, renderChain); + return result == null || result.isNull() ? previousOut : result.asString(); }); } @Override public String prepare(RenderContext renderContext, Resource resource, RenderChain renderChain) throws Exception { return engine.doWithContext(contextProvider -> { - return Value.asValue(getJsFilter(contextProvider).get("prepare")).execute(renderContext, resource, renderChain).asString(); + Map jsFilter = getJsFilter(contextProvider); + if (jsFilter == null || jsFilter.get("prepare") == null) { + return null; + } + Value result = Value.asValue(jsFilter.get("prepare")).execute(renderContext, resource, renderChain); + return result == null || result.isNull() ? null : result.asString(); }); } private Map getJsFilter(ContextProvider contextProvider) { - return contextProvider.getRegistry().get("render-filter", key); + return contextProvider.getRegistry().get(REGISTRY_TYPE, key); } } } diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrarTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrarTest.java new file mode 100644 index 00000000..19d6397b --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrarTest.java @@ -0,0 +1,165 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars; + +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.junit.Before; +import org.junit.Test; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class AbstractServiceRegistrarTest { + + private GraalVMEngine engine; + private BundleContext bundleContext; + private Bundle bundle; + private TestRegistrar registrar; + private List> registryEntries; + + /** Minimal concrete registrar bridging entries to plain Runnable services. */ + private static class TestRegistrar extends AbstractServiceRegistrar { + List> beforeRegisterCalls = new ArrayList<>(); + String failingKey; + + TestRegistrar() { + super(Runnable.class, "test-type"); + } + + @Override + protected Runnable createBridge(Map registryEntry) { + if (registryEntry.get("key").equals(failingKey)) { + throw new IllegalStateException("bridge creation failed for " + failingKey); + } + return () -> { + }; + } + + @Override + protected void beforeRegister(Bundle bundle, Map registryEntry) { + beforeRegisterCalls.add(registryEntry); + } + } + + @Before + public void setUp() { + engine = mock(GraalVMEngine.class); + bundleContext = mock(BundleContext.class); + bundle = mock(Bundle.class); + when(bundle.getSymbolicName()).thenReturn("test-bundle"); + + registryEntries = new ArrayList<>(); + // the registrar reads entries through doWithContext; short-circuit the context here + when(engine.doWithContext(any(Function.class))).thenAnswer(invocation -> registryEntries); + + registrar = new TestRegistrar(); + registrar.graalVMEngine = engine; + registrar.bundleContext = bundleContext; + } + + private Map entry(String key) { + Map entry = new HashMap<>(); + entry.put("type", "test-type"); + entry.put("key", key); + entry.put("bundleKey", "test-bundle"); + return entry; + } + + /** Every registerService call returns a fresh mock, collected for later verification. */ + @SuppressWarnings("unchecked") + private List> stubRegistrations() { + List> created = new ArrayList<>(); + when(bundleContext.registerService(eq(Runnable.class), any(Runnable.class), any())).thenAnswer(invocation -> { + ServiceRegistration registration = mock(ServiceRegistration.class); + created.add(registration); + return registration; + }); + return created; + } + + @Test + public void registerPublishesOneServicePerEntryAndUnregisterReleasesThem() { + registryEntries.addAll(Arrays.asList(entry("a"), entry("b"))); + List> registrations = stubRegistrations(); + + registrar.register(bundle); + verify(bundleContext, times(2)).registerService(eq(Runnable.class), any(Runnable.class), any()); + assertEquals(2, registrar.beforeRegisterCalls.size()); + + registrar.unregister(bundle); + assertEquals(2, registrations.size()); + for (ServiceRegistration registration : registrations) { + verify(registration).unregister(); + } + } + + @Test + public void aFailingBridgeDoesNotPreventOtherEntriesFromRegistering() { + registryEntries.addAll(Arrays.asList(entry("a"), entry("broken"), entry("c"))); + registrar.failingKey = "broken"; + stubRegistrations(); + + registrar.register(bundle); + + verify(bundleContext, times(2)).registerService(eq(Runnable.class), any(Runnable.class), any()); + } + + @Test + public void unregisterUnknownBundleIsANoOp() { + registrar.unregister(bundle); + verify(bundleContext, never()).registerService(eq(Runnable.class), any(Runnable.class), any()); + } + + @Test + public void unregisterSurvivesAFailingServiceUnregistration() { + registryEntries.addAll(Arrays.asList(entry("a"), entry("b"))); + List> registrations = stubRegistrations(); + + registrar.register(bundle); + assertEquals(2, registrations.size()); + doThrow(new IllegalStateException("already unregistered")).when(registrations.get(0)).unregister(); + + registrar.unregister(bundle); + + // both unregistrations attempted despite one of them throwing + for (ServiceRegistration registration : registrations) { + verify(registration).unregister(); + } + } + + @Test + public void registryTypeIsExposed() { + assertTrue(registrar.getRegistryType().equals("test-type")); + } +} From 28c9782d2b4a5bd61d9e3c327ea98419fb110fdd Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Tue, 21 Jul 2026 14:21:54 +0200 Subject: [PATCH 03/36] feat: JS modules can declare choicelist initializers - ChoiceListInitializerRegistrar bridges registry entries of type 'choicelist-initializer' to ModuleChoiceListInitializer OSGi services (consumed by core, usable as choicelist[key] in CND definitions). - New registerChoiceListInitializer() library API: typed callback receiving {param, locale (BCP-47), values, node?, java escape hatch}; returns {label, value, properties?} choices (properties variant supported). - java-ts-bind: generate ExtendedPropertyDefinition (narrow whitelist, flattened parent methods) and Locale.toLanguageTag. - Test-module fixture + CND type, Cypress spec (forms GraphQL API), docs guide. --- .../5-choicelist-initializers/README.md | 70 ++++++++ jahia-test-module/settings/definitions.cnd | 4 + .../react/server/extensions/choicelists.ts | 26 +++ .../.java-ts-bind/package.json | 19 +- javascript-modules-engine-java/pom.xml | 2 + .../ChoiceListInitializerRegistrar.java | 163 ++++++++++++++++++ .../ChoiceListInitializerRegistrarTest.java | 90 ++++++++++ .../registerChoiceListInitializer.ts | 106 ++++++++++++ javascript-modules-library/src/index.ts | 5 + .../e2e/ui/choicelistInitializerTest.cy.ts | 109 ++++++++++++ 10 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 docs/2-guides/5-choicelist-initializers/README.md create mode 100644 jahia-test-module/src/react/server/extensions/choicelists.ts create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrar.java create mode 100644 javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrarTest.java create mode 100644 javascript-modules-library/src/framework/registerChoiceListInitializer.ts create mode 100644 tests/cypress/e2e/ui/choicelistInitializerTest.cy.ts diff --git a/docs/2-guides/5-choicelist-initializers/README.md b/docs/2-guides/5-choicelist-initializers/README.md new file mode 100644 index 00000000..353aee71 --- /dev/null +++ b/docs/2-guides/5-choicelist-initializers/README.md @@ -0,0 +1,70 @@ +--- +page: + $path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/choicelist-initializers + jcr:title: Declaring Choicelist Initializers + j:templateName: documentation +content: + $subpath: document-area/content +--- + +Choicelist initializers populate the dropdown lists offered to editors in Content Editor. Out of the box, Jahia provides initializers such as `resourceBundle` or `nodes`; with JavaScript modules you can declare your own initializers in JavaScript, without writing a Java module. + +## Declaring an initializer + +Call `registerChoiceListInitializer` at the top level of a server file (it registers the initializer as a side effect at module startup, like `jahiaComponent`): + +```ts +import { registerChoiceListInitializer } from "@jahia/javascript-modules-library"; + +registerChoiceListInitializer({ key: "myModuleColors" }, ({ locale }) => [ + { label: locale.startsWith("fr") ? "Rouge" : "Red", value: "red" }, + { label: locale.startsWith("fr") ? "Vert" : "Green", value: "green" }, +]); +``` + +Then reference the initializer's key from a property definition in your CND file: + +```cnd +[mymodule:myComponent] > jnt:content, mix:title + - color (string, choicelist[myModuleColors]) +``` + +The callback returns the list of choices as `{ label, value, properties? }` objects: + +- `label` is the text shown to the editor, +- `value` is the string persisted in the JCR, +- `properties` is optional metadata interpreted by the editing UI, e.g. `{ defaultProperty: true }` to preselect a choice, or `{ image: "/path.png" }` to display a thumbnail. + +## The initializer context + +The callback receives a context object: + +| Property | Description | +|----------|-------------| +| `param` | The parameter from the CND declaration: `choicelist[myModuleColors='myParam']` passes `"myParam"`. Empty string when absent. | +| `locale` | BCP-47 language tag of the editing UI locale (e.g. `"en"`, `"fr"`). Use it to localize labels. | +| `values` | Choices accumulated by previous initializers when several are chained in the CND declaration (e.g. `choicelist[resourceBundle,myModuleColors]`). Include them in your result to keep them. | +| `node` | The node being edited, when it exists (it does not on creation forms). | +| `java` | Escape hatch: the raw Java objects received by the underlying `ModuleChoiceListInitializer` — `propertyDefinition` (`ExtendedPropertyDefinition`), `locale` (`java.util.Locale`), `values`, `context`. | + +For example, an initializer that lists values differently per property and honors a parameter: + +```ts +registerChoiceListInitializer({ key: "myModuleSizes" }, ({ param, values, java }) => { + const sizes = [ + ...values, + { label: "Small", value: "s" }, + { label: "Medium", value: "m" }, + ]; + if (param === "extended") { + sizes.push({ label: `Large (${java.propertyDefinition.getName()})`, value: "l" }); + } + return sizes; +}); +``` + +## Good to know + +- **Keys are platform-wide.** Initializer keys live in a single namespace shared with Java modules; the last registration wins. Prefix your keys with your module name (`myModuleColors`, not `colors`). +- **Keep callbacks fast.** The callback runs synchronously every time an editor form displays the choicelist. +- **Labels are your responsibility.** Unlike `choicelist[resourceBundle]`, labels are not resolved from resource bundles automatically — return localized labels using the `locale` from the context (you can use your module's i18n setup or any custom logic). diff --git a/jahia-test-module/settings/definitions.cnd b/jahia-test-module/settings/definitions.cnd index 11fcce95..b99b5f9f 100644 --- a/jahia-test-module/settings/definitions.cnd +++ b/jahia-test-module/settings/definitions.cnd @@ -155,3 +155,7 @@ [javascriptExample:testVirtualNodeSample] > jnt:content, javascriptExampleMix:javascriptExampleComponent, mix:title - myProperty (string) + +[javascriptExample:testChoicelistInitializer] > jnt:content, javascriptExampleMix:javascriptExampleComponent, mix:title + - color (string, choicelist[testColorsInitializer]) + - colorWithParam (string, choicelist[testColorsInitializer='warm']) diff --git a/jahia-test-module/src/react/server/extensions/choicelists.ts b/jahia-test-module/src/react/server/extensions/choicelists.ts new file mode 100644 index 00000000..ee62f056 --- /dev/null +++ b/jahia-test-module/src/react/server/extensions/choicelists.ts @@ -0,0 +1,26 @@ +import { + registerChoiceListInitializer, + type ChoiceListValue, +} from "@jahia/javascript-modules-library"; + +/** + * Test fixture for JS-declared choicelist initializers, referenced from + * settings/definitions.cnd as choicelist[testColorsInitializer]. + * + * Exercises: localized labels, properties (defaultProperty), the CND parameter + * (choicelist[testColorsInitializer='warm']), previous values passthrough, and the + * raw Java escape hatch (property definition name). + */ +registerChoiceListInitializer({ key: "testColorsInitializer" }, ({ param, locale, values, java }) => { + const choices: ChoiceListValue[] = [ + ...values, + { label: locale.startsWith("fr") ? "Rouge" : "Red", value: "red" }, + { label: "Green", value: "green", properties: { defaultProperty: true } }, + // escape hatch probe: label derived from the raw ExtendedPropertyDefinition + { label: `prop:${java.propertyDefinition.getName()}`, value: "propName" }, + ]; + if (param === "warm") { + choices.push({ label: "Orange", value: "orange" }); + } + return choices; +}); diff --git a/javascript-modules-engine-java/.java-ts-bind/package.json b/javascript-modules-engine-java/.java-ts-bind/package.json index 1fd7be06..7c082606 100644 --- a/javascript-modules-engine-java/.java-ts-bind/package.json +++ b/javascript-modules-engine-java/.java-ts-bind/package.json @@ -29,7 +29,8 @@ "org.jahia.modules.javascript.modules.engine.js.server.RenderHelper", "org.jahia.services.render.RenderContext", "org.jahia.services.render.Resource", - "org.jahia.services.content.JCRNodeWrapper" + "org.jahia.services.content.JCRNodeWrapper", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition" ], "include": [ "java.io.BufferedReader", @@ -92,6 +93,8 @@ "org.jahia.services.content.QueryManagerWrapper", "org.jahia.services.content.decorator.JCRNodeDecorator", "org.jahia.services.content.decorator.JCRSiteNode", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition", "org.jahia.services.query.QueryResultWrapper", "org.jahia.services.query.QueryWrapper", "org.jahia.services.render.RenderContext", @@ -146,6 +149,7 @@ "java.util.List.iterator", "java.util.List.size", "java.util.Locale.get.*", + "java.util.Locale.toLanguageTag", "java.util.Locale.toString", "java.util.Map.containsKey", "java.util.Map.get.*", @@ -321,6 +325,18 @@ "org.jahia.services.content.decorator.JCRSiteNode.getUUID", "org.jahia.services.content.decorator.JCRSiteNode.getWeakReferences.*", "org.jahia.services.content.decorator.JCRSiteNode.is.*", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition.getName", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition.getSelectorOptions", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition.isHidden", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition.isMandatory", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.getName", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.getRequiredType", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.getSelectorOptions", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.getValueConstraints", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.isHidden", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.isInternationalized", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.isMandatory", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.isMultiple", "org.jahia.services.query.QueryResultWrapper.getApproxCount", "org.jahia.services.query.QueryResultWrapper.getNodes", "org.jahia.services.query.QueryWrapper.bindValue", @@ -450,7 +466,6 @@ "org.jahia.services.content.decorator.JCRPlaceholderNode", "org.jahia.services.content.decorator.JCRUserNode", "org.jahia.services.content.nodetypes.ExtendedNodeDefinition", - "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition", "org.jahia.services.content.nodetypes.NodeTypeWrapper", "org.jahia.services.pwd.PasswordService", "org.jahia.services.query.QueryResultAdapter", diff --git a/javascript-modules-engine-java/pom.xml b/javascript-modules-engine-java/pom.xml index c01d953d..c38cb536 100644 --- a/javascript-modules-engine-java/pom.xml +++ b/javascript-modules-engine-java/pom.xml @@ -328,6 +328,8 @@ io.github.bensku:java-ts-bind + + org.graalvm.js:js org.apache.jackrabbit:jackrabbit-spi-commons org.jboss.spec.javax.servlet:jboss-servlet-api_3.1_spec diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrar.java new file mode 100644 index 00000000..33c933c9 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrar.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars; + +import org.apache.jackrabbit.value.StringValue; +import org.graalvm.polyglot.Value; +import org.jahia.modules.javascript.modules.engine.jsengine.ContextProvider; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.jahia.services.content.nodetypes.ExtendedPropertyDefinition; +import org.jahia.services.content.nodetypes.initializers.ChoiceListInitializerService; +import org.jahia.services.content.nodetypes.initializers.ChoiceListValue; +import org.jahia.services.content.nodetypes.initializers.ModuleChoiceListInitializer; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Exposes JavaScript registry entries of type {@code choicelist-initializer} as + * {@link ModuleChoiceListInitializer} OSGi services, consumed by Jahia core and usable from CND definitions + * as {@code choicelist[key]} selector options. + */ +@Component(service = Registrar.class, immediate = true) +public class ChoiceListInitializerRegistrar extends AbstractServiceRegistrar { + + public static final String REGISTRY_TYPE = "choicelist-initializer"; + + private static final Logger logger = LoggerFactory.getLogger(ChoiceListInitializerRegistrar.class); + + private ChoiceListInitializerService choiceListInitializerService; + + public ChoiceListInitializerRegistrar() { + super(ModuleChoiceListInitializer.class, REGISTRY_TYPE); + } + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + public void setGraalVMEngine(GraalVMEngine graalVMEngine) { + this.graalVMEngine = graalVMEngine; + } + + @Reference + public void setChoiceListInitializerService(ChoiceListInitializerService choiceListInitializerService) { + this.choiceListInitializerService = choiceListInitializerService; + } + + @Activate + public void activate(BundleContext bundleContext) { + this.bundleContext = bundleContext; + } + + @Override + protected void beforeRegister(Bundle bundle, Map registryEntry) { + Object key = registryEntry.get("key"); + if (key != null && choiceListInitializerService.getInitializers().containsKey(key.toString())) { + logger.warn("A choicelist initializer with key '{}' is already registered on this platform; " + + "the one declared by bundle {} will take precedence (last registration wins). " + + "Consider prefixing initializer keys with the module name.", key, bundle.getSymbolicName()); + } + } + + @Override + protected ModuleChoiceListInitializer createBridge(Map registryEntry) { + return new ChoiceListInitializerBridge(registryEntry, graalVMEngine); + } + + public static class ChoiceListInitializerBridge implements ModuleChoiceListInitializer { + + private final GraalVMEngine engine; + private String key; + + public ChoiceListInitializerBridge(Map value, GraalVMEngine engine) { + this.engine = engine; + this.key = (String) value.get("key"); + } + + @Override + public void setKey(String key) { + this.key = key; + } + + @Override + public String getKey() { + return key; + } + + @Override + public List getChoiceListValues(ExtendedPropertyDefinition epd, String param, + List values, Locale locale, Map context) { + return engine.doWithContext(contextProvider -> { + Map entry = getJsInitializer(contextProvider); + if (entry == null || entry.get("getChoiceListValues") == null) { + logger.warn("JS choicelist initializer '{}' is no longer available in the registry, " + + "returning no values", key); + return Collections.emptyList(); + } + Value result = Value.asValue(entry.get("getChoiceListValues")) + .execute(epd, param, values, locale, context); + return convertValues(result, key); + }); + } + + private Map getJsInitializer(ContextProvider contextProvider) { + return contextProvider.getRegistry().get(REGISTRY_TYPE, key); + } + + /** + * Converts a JS array of {@code {label, value, properties?}} objects into Jahia + * {@link ChoiceListValue} instances. Malformed items are logged and skipped. + */ + static List convertValues(Value result, String key) { + if (result == null || result.isNull() || !result.hasArrayElements()) { + return Collections.emptyList(); + } + List choiceListValues = new ArrayList<>(); + for (long i = 0; i < result.getArraySize(); i++) { + Value item = result.getArrayElement(i); + Value label = item.getMember("label"); + Value value = item.getMember("value"); + if (label == null || label.isNull() || value == null || value.isNull()) { + logger.warn("JS choicelist initializer '{}' returned an item without label or value " + + "at index {}, skipping it", key, i); + continue; + } + Value properties = item.getMember("properties"); + if (properties != null && !properties.isNull() && properties.hasMembers()) { + Map propertiesMap = new HashMap<>(); + for (String memberKey : properties.getMemberKeys()) { + propertiesMap.put(memberKey, properties.getMember(memberKey).as(Object.class)); + } + choiceListValues.add(new ChoiceListValue(label.asString(), propertiesMap, + new StringValue(value.asString()))); + } else { + choiceListValues.add(new ChoiceListValue(label.asString(), value.asString())); + } + } + return choiceListValues; + } + } +} diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrarTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrarTest.java new file mode 100644 index 00000000..584ead91 --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrarTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; +import org.jahia.services.content.nodetypes.initializers.ChoiceListValue; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.jcr.RepositoryException; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ChoiceListInitializerRegistrarTest { + + private static Context context; + + @BeforeClass + public static void setUp() { + context = Context.newBuilder("js").build(); + } + + @AfterClass + public static void tearDown() { + context.close(); + } + + private static List convert(String jsExpression) { + Value result = context.eval("js", jsExpression); + return ChoiceListInitializerRegistrar.ChoiceListInitializerBridge.convertValues(result, "test"); + } + + @Test + public void convertsLabelValuePairs() throws RepositoryException { + List values = convert("[{label: 'Red', value: 'red'}, {label: 'Green', value: 'green'}]"); + + assertEquals(2, values.size()); + assertEquals("Red", values.get(0).getDisplayName()); + assertEquals("red", values.get(0).getValue().getString()); + assertNull(values.get(0).getProperties()); + assertEquals("Green", values.get(1).getDisplayName()); + } + + @Test + public void convertsProperties() throws RepositoryException { + List values = convert( + "[{label: 'Blue', value: 'blue', properties: {defaultProperty: true, image: '/img.png'}}]"); + + assertEquals(1, values.size()); + assertEquals("Blue", values.get(0).getDisplayName()); + assertEquals("blue", values.get(0).getValue().getString()); + assertEquals(Boolean.TRUE, values.get(0).getProperties().get("defaultProperty")); + assertEquals("/img.png", values.get(0).getProperties().get("image")); + } + + @Test + public void skipsMalformedItems() { + List values = convert( + "[{label: 'ok', value: 'ok'}, {label: 'missing value'}, {value: 'missing label'}, {}]"); + + assertEquals(1, values.size()); + assertEquals("ok", values.get(0).getDisplayName()); + } + + @Test + public void nonArrayResultsYieldNoValues() { + assertTrue(convert("null").isEmpty()); + assertTrue(convert("undefined").isEmpty()); + assertTrue(convert("({label: 'not-an-array', value: 'x'})").isEmpty()); + assertTrue(convert("[]").isEmpty()); + } +} diff --git a/javascript-modules-library/src/framework/registerChoiceListInitializer.ts b/javascript-modules-library/src/framework/registerChoiceListInitializer.ts new file mode 100644 index 00000000..427ab89d --- /dev/null +++ b/javascript-modules-library/src/framework/registerChoiceListInitializer.ts @@ -0,0 +1,106 @@ +import type { JCRNodeWrapper } from "org.jahia.services.content"; +import type { ExtendedPropertyDefinition } from "org.jahia.services.content.nodetypes"; +import type { List, Locale, Map as JavaMap } from "java.util"; + +/** One selectable entry of a choicelist. */ +export interface ChoiceListValue { + /** Human-readable label shown in the editing UI. */ + label: string; + /** String persisted in the JCR when this choice is selected. */ + value: string; + /** + * Optional metadata attached to the choice, interpreted by the editing UI (e.g. + * `{ image: "/path.png" }` or `{ defaultProperty: true }`). + */ + properties?: Record; +} + +/** Context passed to a choicelist initializer callback. */ +export interface ChoiceListInitializerContext { + /** + * Parameter from the CND declaration `choicelist[myKey,'myParam']`; empty string when absent. + */ + param: string; + /** BCP-47 language tag of the editing UI locale, e.g. `"en"` or `"fr-FR"`. */ + locale: string; + /** + * Choices accumulated by the previous initializers of the CND declaration chain (empty when this + * initializer is used alone). Return them as part of your result to keep them. + */ + values: ChoiceListValue[]; + /** The node being edited, when available (not available on creation forms). */ + node?: JCRNodeWrapper; + /** Escape hatch: the raw Java objects received by the underlying ModuleChoiceListInitializer. */ + java: { + propertyDefinition: ExtendedPropertyDefinition; + locale: Locale; + values: List; + context: JavaMap; + }; +} + +/** + * Registers a choicelist initializer, usable from CND property definitions to populate dropdowns in + * the editing UI: + * + * ```cnd + * - color (string, choicelist[myModuleColors]) + * ``` + * + * ```ts + * registerChoiceListInitializer({ key: "myModuleColors" }, ({ locale }) => [ + * { label: locale === "fr" ? "Rouge" : "Red", value: "red" }, + * { label: locale === "fr" ? "Vert" : "Green", value: "green" }, + * ]); + * ``` + * + * Keys live in a single platform-wide namespace shared with Java modules (last registration wins); + * prefix them with your module name to avoid collisions. + * + * The callback runs synchronously on a server thread every time an editor form displays the + * choicelist — keep it fast. + * + * @param options The initializer declaration; `key` is the name referenced from CND definitions. + * @param resolveValues Returns the choices offered to the editor. + */ +export const registerChoiceListInitializer = ( + { key }: { key: string }, + resolveValues: (context: ChoiceListInitializerContext) => ChoiceListValue[], +): void => { + server.registry.add("choicelist-initializer", key, { + // Raw adapter invoked by the Java bridge (ChoiceListInitializerRegistrar) with the + // ModuleChoiceListInitializer#getChoiceListValues arguments. Keep both shapes in sync. + getChoiceListValues: ( + propertyDefinition: ExtendedPropertyDefinition, + param: string | null, + values: List, + locale: Locale, + context: JavaMap, + ): ChoiceListValue[] => + resolveValues({ + param: param ?? "", + locale: locale ? locale.toLanguageTag() : "", + values: toJsChoiceListValues(values), + node: (context?.get("contextNode") as JCRNodeWrapper | null) ?? undefined, + java: { propertyDefinition, locale, values, context }, + }), + }); + console.debug(`Registered choicelist initializer: ${key}`); +}; + +/** Converts the Java List of org.jahia...ChoiceListValue accumulated so far into plain JS objects. */ +const toJsChoiceListValues = (values: List): ChoiceListValue[] => { + const result: ChoiceListValue[] = []; + if (values) { + for (let i = 0; i < values.size(); i++) { + // Jahia's ChoiceListValue: getDisplayName(), getValue() (JCR Value), getProperties() + const value = values.get(i) as { + getDisplayName(): string; + getValue(): { getString(): string }; + getProperties(): JavaMap | null; + }; + result.push({ label: value.getDisplayName(), value: value.getValue().getString() }); + } + } + return result; +}; diff --git a/javascript-modules-library/src/index.ts b/javascript-modules-library/src/index.ts index 276252f4..474cf446 100644 --- a/javascript-modules-library/src/index.ts +++ b/javascript-modules-library/src/index.ts @@ -12,6 +12,11 @@ export { Area } from "./components/Area.js"; // Declaration and registration export { jahiaComponent } from "./framework/jahiaComponent.js"; +export { + registerChoiceListInitializer, + type ChoiceListValue, + type ChoiceListInitializerContext, +} from "./framework/registerChoiceListInitializer.js"; // Hooks export { useGQLQuery } from "./hooks/useGQLQuery.js"; diff --git a/tests/cypress/e2e/ui/choicelistInitializerTest.cy.ts b/tests/cypress/e2e/ui/choicelistInitializerTest.cy.ts new file mode 100644 index 00000000..3585753a --- /dev/null +++ b/tests/cypress/e2e/ui/choicelistInitializerTest.cy.ts @@ -0,0 +1,109 @@ +import { GENERIC_SITE_KEY } from "../../support/constants"; + +interface ValueConstraint { + displayValue: string; + value: { string: string }; + properties: Array<{ name: string; value: string }>; +} + +interface Field { + name: string; + valueConstraints: ValueConstraint[]; +} + +const FORM_QUERY = ` + query createForm($nodeType: String!, $uiLocale: String!, $locale: String!, $uuidOrPath: String!) { + forms { + createForm(primaryNodeType: $nodeType, uiLocale: $uiLocale, locale: $locale, uuidOrPath: $uuidOrPath) { + sections { + fieldSets { + fields { + name + valueConstraints { + displayValue + value { string } + properties { name value } + } + } + } + } + } + } + } +`; + +/** Fetches the creation form of the test node type and returns its fields, flattened. */ +const getFormFields = (uiLocale: string): Cypress.Chainable => + cy + .apollo({ + query: FORM_QUERY, + variables: { + nodeType: "javascriptExample:testChoicelistInitializer", + uiLocale, + locale: "en", + uuidOrPath: `/sites/${GENERIC_SITE_KEY}/home`, + }, + }) + .then((response) => + response.data.forms.createForm.sections.flatMap((section: { fieldSets: Array<{ fields: Field[] }> }) => + section.fieldSets.flatMap((fieldSet) => fieldSet.fields), + ), + ); + +const field = (fields: Field[], name: string): Field => { + const match = fields.find((f) => f.name === name); + expect(match, `field ${name} present in the form`).to.exist; + return match; +}; + +const constraintValues = (f: Field): string[] => f.valueConstraints.map((c) => c.value.string); +const constraintLabel = (f: Field, value: string): string => + f.valueConstraints.find((c) => c.value.string === value)?.displayValue; + +describe("JS choicelist initializers", () => { + beforeEach("Login", () => { + cy.login(); + }); + afterEach("Logout", () => { + cy.logout(); + }); + + it("populates the choicelist declared in JS", () => { + getFormFields("en").then((fields) => { + const color = field(fields, "color"); + expect(constraintValues(color)).to.include.members(["red", "green", "propName"]); + expect(constraintLabel(color, "red")).to.equal("Red"); + expect(constraintLabel(color, "green")).to.equal("Green"); + }); + }); + + it("exposes choice properties (defaultProperty)", () => { + getFormFields("en").then((fields) => { + const green = field(fields, "color").valueConstraints.find((c) => c.value.string === "green"); + const defaultProperty = green.properties.find((p) => p.name === "defaultProperty"); + expect(defaultProperty, "defaultProperty on green").to.exist; + expect(String(defaultProperty.value)).to.equal("true"); + }); + }); + + it("passes the CND parameter to the initializer", () => { + getFormFields("en").then((fields) => { + expect(constraintValues(field(fields, "colorWithParam"))).to.include("orange"); + expect(constraintValues(field(fields, "color"))).to.not.include("orange"); + }); + }); + + it("gives the initializer access to the raw property definition (escape hatch)", () => { + getFormFields("en").then((fields) => { + // the fixture derives a label from ExtendedPropertyDefinition#getName() + expect(constraintLabel(field(fields, "color"), "propName")).to.equal("prop:color"); + expect(constraintLabel(field(fields, "colorWithParam"), "propName")).to.equal("prop:colorWithParam"); + }); + }); + + it("localizes labels through the ui locale", () => { + getFormFields("fr").then((fields) => { + expect(constraintLabel(field(fields, "color"), "red")).to.equal("Rouge"); + }); + }); +}); From 8c5eeffc56ae8071a3bf13a8f2c7d654522f99da Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Tue, 21 Jul 2026 20:15:08 +0200 Subject: [PATCH 04/36] feat: JS modules can declare actions - ActionRegistrar bridges registry entries of type 'action' to org.jahia.bin.Action OSGi services, invoked via ..do. - New registerAction() library API: typed declaration (requiredMethods, requireAuthenticatedUser [Jahia default: true], requiredPermission, requiredWorkspace) and handler receiving {parameters, renderContext, resource, session, request, urlResolver}; result {statusCode, json, redirect, absoluteRedirect}. The adapter pre-stringifies json to avoid polyglot deep-conversion issues with nested structures. - CSRF stays the module's responsibility (ADR-0004): documented recipe + test-module .cfg exercising it end-to-end. - java-ts-bind: generate URLResolver (narrow whitelist). - Test-module fixtures (GET/POST/auth/redirect), Cypress spec incl. redeploy resilience, docs guide. --- docs/2-guides/4-actions/README.md | 79 +++++++++ ...g.jahia.modules.jahiacsrfguard-jsmtest.cfg | 3 + .../src/react/server/extensions/actions.ts | 40 +++++ .../.java-ts-bind/package.json | 9 +- javascript-modules-engine-java/pom.xml | 16 ++ .../engine/registrars/ActionRegistrar.java | 157 ++++++++++++++++++ .../registrars/ActionRegistrarTest.java | 111 +++++++++++++ .../src/framework/registerAction.ts | 148 +++++++++++++++++ javascript-modules-library/src/index.ts | 6 + tests/cypress/e2e/ui/actionTest.cy.ts | 93 +++++++++++ 10 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 docs/2-guides/4-actions/README.md create mode 100644 jahia-test-module/settings/configurations/org.jahia.modules.jahiacsrfguard-jsmtest.cfg create mode 100644 jahia-test-module/src/react/server/extensions/actions.ts create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ActionRegistrar.java create mode 100644 javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/ActionRegistrarTest.java create mode 100644 javascript-modules-library/src/framework/registerAction.ts create mode 100644 tests/cypress/e2e/ui/actionTest.cy.ts diff --git a/docs/2-guides/4-actions/README.md b/docs/2-guides/4-actions/README.md new file mode 100644 index 00000000..5a6f65ef --- /dev/null +++ b/docs/2-guides/4-actions/README.md @@ -0,0 +1,79 @@ +--- +page: + $path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/actions + jcr:title: Declaring Actions + j:templateName: documentation +content: + $subpath: document-area/content +--- + +Actions are HTTP endpoints bound to content nodes: appending `..do` to a node URL invokes the action against that node. They are the classic Jahia mechanism for form submissions and lightweight server endpoints. With JavaScript modules you can declare actions in JavaScript, without writing a Java module. + +## Declaring an action + +Call `registerAction` at the top level of a server file (it registers the action as a side effect at module startup, like `jahiaComponent`): + +```ts +import { registerAction } from "@jahia/javascript-modules-library"; + +registerAction( + { name: "myModuleGreet", requiredMethods: ["GET"], requireAuthenticatedUser: false }, + ({ parameters, resource }) => ({ + json: { + greeting: `Hello ${parameters.who?.[0] ?? "world"}`, + path: resource.getNode().getPath(), + }, + }), +); +``` + +The action is then reachable on any node URL: + +``` +GET /cms/render/live/en/sites/mysite/home.myModuleGreet.do?who=Jahia +→ 200 {"greeting": "Hello Jahia", "path": "/sites/mysite/home"} +``` + +## Declaration options + +| Option | Description | +|--------|-------------| +| `name` | The URL-visible action name. Names are platform-wide (shared with Java modules, last registration wins) — prefix them with your module name. | +| `requiredMethods` | Allowed HTTP methods, e.g. `["POST"]`. Defaults to Jahia's default (GET and POST). | +| `requireAuthenticatedUser` | Defaults to **`true`** (Jahia's default): guests get a 401. Set to `false` explicitly for public actions. | +| `requiredPermission` | Permission required on the target node, e.g. `"jcr:write"`. | +| `requiredWorkspace` | Restrict to `"default"` or `"live"`. | + +## The handler + +The handler receives a context object: + +- `parameters` — merged query-string and form parameters, as `Record`, +- `resource` / `renderContext` / `session` — the target resource, render context and user JCR session, +- `request` — escape hatch: the raw `HttpServletRequest` (headers, cookies, body), +- `urlResolver` — escape hatch: the Jahia URL resolver. + +And returns (synchronously — no promises): + +- `json` — an object serialized as the JSON response body, +- `statusCode` — HTTP status, default 200, +- `redirect` (+ `absoluteRedirect`) — redirect the client instead of returning a body. + +Returning nothing sends an empty 200. + +## CSRF protection for POST actions + +POST, PUT and DELETE requests to `.do` URLs are blocked by Jahia's CSRF guard unless the URL is whitelisted. **This is your module's responsibility**: ship an OSGi configuration file in your module's `settings/configurations/` folder: + +```properties +# settings/configurations/org.jahia.modules.jahiacsrfguard-mymodule.cfg +whitelist = *.myModuleSubmit.do,*.myModuleOther.do +``` + +Whitelisting disables CSRF protection for those URLs, so only do it for actions designed to be called without a CSRF token (e.g. public form submissions), and keep the patterns as narrow as possible. Without this file, POST calls to your action fail with a 403. + +## Good to know + +- **Keep handlers fast and non-blocking** — they run synchronously on a request thread. +- **Content modifications**: use the provided `session` to read/write JCR content as the calling user; standard permissions apply, plus `requiredPermission` if you set it. +- **Errors**: an exception thrown by the handler results in an error response; validate input and return explicit `statusCode` values (e.g. 400) for expected failures. diff --git a/jahia-test-module/settings/configurations/org.jahia.modules.jahiacsrfguard-jsmtest.cfg b/jahia-test-module/settings/configurations/org.jahia.modules.jahiacsrfguard-jsmtest.cfg new file mode 100644 index 00000000..9997cac4 --- /dev/null +++ b/jahia-test-module/settings/configurations/org.jahia.modules.jahiacsrfguard-jsmtest.cfg @@ -0,0 +1,3 @@ +# Whitelists the POST test action in Jahia's CSRF guard, following the documented recipe +# for JS modules exposing actions to unsafe HTTP methods (see docs/2-guides/4-actions). +whitelist = *.testJsActionPost.do diff --git a/jahia-test-module/src/react/server/extensions/actions.ts b/jahia-test-module/src/react/server/extensions/actions.ts new file mode 100644 index 00000000..6bdba30d --- /dev/null +++ b/jahia-test-module/src/react/server/extensions/actions.ts @@ -0,0 +1,40 @@ +import { registerAction } from "@jahia/javascript-modules-library"; + +/** + * Test fixtures for JS-declared actions, invoked via ..do URLs. + * + * Exercises: GET with query parameters and node access, POST (CSRF-whitelisted via + * settings/configurations/org.jahia.modules.jahiacsrfguard-jsmtest.cfg), authentication + * requirement, redirects, and method restrictions. + */ + +registerAction( + { name: "testJsActionGet", requiredMethods: ["GET"], requireAuthenticatedUser: false }, + ({ parameters, resource }) => ({ + json: { + echo: parameters.echo?.[0] ?? null, + path: resource.getNode().getPath(), + }, + }), +); + +registerAction( + { name: "testJsActionPost", requiredMethods: ["POST"], requireAuthenticatedUser: false }, + ({ parameters }) => ({ + statusCode: 201, + json: { received: parameters.payload?.[0] ?? null }, + }), +); + +// requireAuthenticatedUser defaults to true: guests get a 401 +registerAction({ name: "testJsActionAuth", requiredMethods: ["GET"] }, ({ renderContext }) => ({ + json: { user: renderContext.getUser().getName() }, +})); + +registerAction( + { name: "testJsActionRedirect", requiredMethods: ["GET"], requireAuthenticatedUser: false }, + () => ({ + statusCode: 302, + redirect: "/redirected-target", + }), +); diff --git a/javascript-modules-engine-java/.java-ts-bind/package.json b/javascript-modules-engine-java/.java-ts-bind/package.json index 7c082606..8bd0824f 100644 --- a/javascript-modules-engine-java/.java-ts-bind/package.json +++ b/javascript-modules-engine-java/.java-ts-bind/package.json @@ -30,7 +30,8 @@ "org.jahia.services.render.RenderContext", "org.jahia.services.render.Resource", "org.jahia.services.content.JCRNodeWrapper", - "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition" + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition", + "org.jahia.services.render.URLResolver" ], "include": [ "java.io.BufferedReader", @@ -100,6 +101,7 @@ "org.jahia.services.render.RenderContext", "org.jahia.services.render.Resource", "org.jahia.services.render.URLGenerator", + "org.jahia.services.render.URLResolver", "org.jahia.services.sites.JahiaSite", "org.jahia.services.usermanager.JahiaPrincipal", "org.jahia.services.usermanager.JahiaUser", @@ -352,6 +354,11 @@ "org.jahia.services.render.Resource.is.*", "org.jahia.services.render.URLGenerator.get.*", "org.jahia.services.render.URLGenerator.is.*", + "org.jahia.services.render.URLResolver.getLocale", + "org.jahia.services.render.URLResolver.getPath", + "org.jahia.services.render.URLResolver.getSiteKey", + "org.jahia.services.render.URLResolver.getUrlPathInfo", + "org.jahia.services.render.URLResolver.getWorkspace", "org.jahia.services.usermanager.JahiaUser.get.*", "org.jahia.services.usermanager.JahiaUser.is.*", "org.osgi.framework.Bundle.get.*", diff --git a/javascript-modules-engine-java/pom.xml b/javascript-modules-engine-java/pom.xml index c38cb536..a338198e 100644 --- a/javascript-modules-engine-java/pom.xml +++ b/javascript-modules-engine-java/pom.xml @@ -109,6 +109,13 @@ jdom2 provided + + + org.json + json + 20231013 + provided + org.jahia.server jahia-impl @@ -220,6 +227,13 @@ js test + + + commons-lang + commons-lang + 2.6 + test + @@ -330,6 +344,8 @@ org.graalvm.js:js + + commons-lang:commons-lang org.apache.jackrabbit:jackrabbit-spi-commons org.jboss.spec.javax.servlet:jboss-servlet-api_3.1_spec diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ActionRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ActionRegistrar.java new file mode 100644 index 00000000..ba65496a --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ActionRegistrar.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars; + +import org.graalvm.polyglot.Value; +import org.jahia.bin.Action; +import org.jahia.bin.ActionResult; +import org.jahia.modules.javascript.modules.engine.jsengine.ContextProvider; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.jahia.services.content.JCRSessionWrapper; +import org.jahia.services.render.RenderContext; +import org.jahia.services.render.Resource; +import org.jahia.services.render.URLResolver; +import org.jahia.services.templates.JahiaTemplateManagerService; +import org.json.JSONObject; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Map; + +/** + * Exposes JavaScript registry entries of type {@code action} as {@link Action} OSGi services, consumed by + * Jahia core and invoked through {@code ..do} URLs. + */ +@Component(service = Registrar.class, immediate = true) +public class ActionRegistrar extends AbstractServiceRegistrar { + + public static final String REGISTRY_TYPE = "action"; + + private static final Logger logger = LoggerFactory.getLogger(ActionRegistrar.class); + + private JahiaTemplateManagerService templateManagerService; + + public ActionRegistrar() { + super(Action.class, REGISTRY_TYPE); + } + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + public void setGraalVMEngine(GraalVMEngine graalVMEngine) { + this.graalVMEngine = graalVMEngine; + } + + @Reference + public void setTemplateManagerService(JahiaTemplateManagerService templateManagerService) { + this.templateManagerService = templateManagerService; + } + + @Activate + public void activate(BundleContext bundleContext) { + this.bundleContext = bundleContext; + } + + @Override + protected void beforeRegister(Bundle bundle, Map registryEntry) { + Object key = registryEntry.get("key"); + if (key != null + && templateManagerService.getTemplatePackageRegistry().getActions().containsKey(key.toString())) { + logger.warn("An action named '{}' is already registered on this platform; the one declared by " + + "bundle {} will take precedence (last registration wins). " + + "Consider prefixing action names with the module name.", key, bundle.getSymbolicName()); + } + } + + @Override + protected Action createBridge(Map registryEntry) { + return new ActionBridge(registryEntry, graalVMEngine); + } + + public static class ActionBridge extends Action { + + private final GraalVMEngine engine; + + public ActionBridge(Map value, GraalVMEngine engine) { + this.engine = engine; + setName((String) value.get("key")); + if (value.containsKey("requiredMethods")) { + setRequiredMethods(value.get("requiredMethods").toString()); + } + if (value.containsKey("requireAuthenticatedUser")) { + setRequireAuthenticatedUser((Boolean) value.get("requireAuthenticatedUser")); + } + if (value.containsKey("requiredPermission")) { + setRequiredPermission(value.get("requiredPermission").toString()); + } + if (value.containsKey("requiredWorkspace")) { + setRequiredWorkspace(value.get("requiredWorkspace").toString()); + } + } + + @Override + public ActionResult doExecute(HttpServletRequest request, RenderContext renderContext, Resource resource, + JCRSessionWrapper session, Map> parameters, URLResolver urlResolver) + throws Exception { + return engine.doWithContext(contextProvider -> { + Map entry = getJsAction(contextProvider); + if (entry == null || entry.get("doExecute") == null) { + logger.warn("JS action '{}' is no longer available in the registry", getName()); + return ActionResult.SERVICE_UNAVAILABLE; + } + Value result = Value.asValue(entry.get("doExecute")) + .execute(request, renderContext, resource, session, parameters, urlResolver); + return convertResult(result); + }); + } + + private Map getJsAction(ContextProvider contextProvider) { + return contextProvider.getRegistry().get(REGISTRY_TYPE, getName()); + } + + /** + * Converts the JS adapter result ({@code {statusCode?, json?: string, redirect?, absoluteRedirect?}}, + * with {@code json} pre-stringified on the JS side to avoid polyglot deep-conversion pitfalls) into an + * {@link ActionResult}. + */ + static ActionResult convertResult(Value result) { + if (result == null || result.isNull()) { + return new ActionResult(HttpServletResponse.SC_OK); + } + int statusCode = result.hasMember("statusCode") && !result.getMember("statusCode").isNull() + ? result.getMember("statusCode").asInt() + : HttpServletResponse.SC_OK; + String redirect = result.hasMember("redirect") && !result.getMember("redirect").isNull() + ? result.getMember("redirect").asString() + : null; + boolean absoluteRedirect = result.hasMember("absoluteRedirect") + && !result.getMember("absoluteRedirect").isNull() + && result.getMember("absoluteRedirect").asBoolean(); + JSONObject json = null; + if (result.hasMember("json") && !result.getMember("json").isNull()) { + json = new JSONObject(result.getMember("json").asString()); + } + return new ActionResult(statusCode, redirect, absoluteRedirect, json); + } + } +} diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/ActionRegistrarTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/ActionRegistrarTest.java new file mode 100644 index 00000000..3c9c49a5 --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/ActionRegistrarTest.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; +import org.jahia.bin.ActionResult; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ActionRegistrarTest { + + private static Context context; + + @BeforeClass + public static void setUp() { + context = Context.newBuilder("js").build(); + } + + @AfterClass + public static void tearDown() { + context.close(); + } + + private static ActionResult convert(String jsExpression) { + Value result = context.eval("js", jsExpression); + return ActionRegistrar.ActionBridge.convertResult(result); + } + + @Test + public void declarationIsMappedOntoTheActionBaseClass() { + Map entry = new HashMap<>(); + entry.put("key", "myAction"); + entry.put("requiredMethods", "GET,POST"); + entry.put("requireAuthenticatedUser", Boolean.TRUE); + entry.put("requiredPermission", "jcr:write"); + entry.put("requiredWorkspace", "live"); + + ActionRegistrar.ActionBridge bridge = new ActionRegistrar.ActionBridge(entry, null); + + assertEquals("myAction", bridge.getName()); + assertTrue(bridge.getRequiredMethods().contains("GET")); + assertTrue(bridge.getRequiredMethods().contains("POST")); + assertTrue(bridge.isRequireAuthenticatedUser()); + assertEquals("jcr:write", bridge.getRequiredPermission()); + assertEquals("live", bridge.getRequiredWorkspace()); + } + + @Test + public void absentDeclarationKeysKeepBaseClassDefaults() { + Map entry = new HashMap<>(); + entry.put("key", "minimalAction"); + + ActionRegistrar.ActionBridge bridge = new ActionRegistrar.ActionBridge(entry, null); + + assertEquals("minimalAction", bridge.getName()); + // Jahia's Action base class requires an authenticated user by default + assertTrue(bridge.isRequireAuthenticatedUser()); + assertNull(bridge.getRequiredPermission()); + } + + @Test + public void convertsAFullResult() { + ActionResult result = convert( + "({statusCode: 201, json: JSON.stringify({message: 'ok', nested: {list: [1, 2]}}), " + + "redirect: '/somewhere', absoluteRedirect: true})"); + + assertEquals(201, result.getResultCode()); + assertEquals("/somewhere", result.getUrl()); + assertTrue(result.isAbsoluteUrl()); + assertEquals("ok", result.getJson().getString("message")); + assertEquals(2, result.getJson().getJSONObject("nested").getJSONArray("list").length()); + } + + @Test + public void defaultsToHttp200() { + ActionResult result = convert("({})"); + assertEquals(200, result.getResultCode()); + assertNull(result.getUrl()); + assertFalse(result.isAbsoluteUrl()); + assertNull(result.getJson()); + } + + @Test + public void nullAndUndefinedResultsYieldAnEmpty200() { + assertEquals(200, convert("null").getResultCode()); + assertEquals(200, convert("undefined").getResultCode()); + } +} diff --git a/javascript-modules-library/src/framework/registerAction.ts b/javascript-modules-library/src/framework/registerAction.ts new file mode 100644 index 00000000..0877d7f9 --- /dev/null +++ b/javascript-modules-library/src/framework/registerAction.ts @@ -0,0 +1,148 @@ +import type { JCRSessionWrapper } from "org.jahia.services.content"; +import type { RenderContext, Resource, URLResolver } from "org.jahia.services.render"; +import type { HttpServletRequest } from "javax.servlet.http"; +import type { List, Map as JavaMap } from "java.util"; + +/** Declaration of an action, invoked through `..do` URLs. */ +export interface ActionDeclaration { + /** + * The action name; the action is triggered by URLs of the form `..do`. + * + * Names live in a single platform-wide namespace shared with Java modules (last registration + * wins); prefix them with your module name to avoid collisions. + */ + name: string; + /** + * HTTP methods allowed to trigger the action. When omitted, Jahia's default applies (GET and + * POST). Note that POST/PUT/DELETE requests to `.do` URLs must be whitelisted in Jahia's CSRF + * guard configuration — see the actions documentation. + */ + requiredMethods?: ("GET" | "POST" | "PUT" | "DELETE")[]; + /** + * Restrict the action to authenticated users. + * + * @default true (Jahia's default — set it to false explicitly for guest-accessible actions) + */ + requireAuthenticatedUser?: boolean; + /** Permission required on the target node to execute the action, e.g. `"jcr:write"`. */ + requiredPermission?: string; + /** Restrict the action to a workspace. */ + requiredWorkspace?: "default" | "live"; +} + +/** Context passed to an action handler. */ +export interface ActionContext { + /** Merged query-string and form parameters of the request. */ + parameters: Record; + /** The render context of the action request. */ + renderContext: RenderContext; + /** The resource targeted by the action URL. */ + resource: Resource; + /** The JCR session of the calling user. */ + session: JCRSessionWrapper; + /** Escape hatch: the raw servlet request (headers, cookies, body). */ + request: HttpServletRequest; + /** Escape hatch: the Jahia URL resolver for the action URL. */ + urlResolver: URLResolver; +} + +/** Result of an action handler. */ +export interface ActionResult { + /** HTTP status code of the response. @default 200 */ + statusCode?: number; + /** Serialized as the JSON response body. Must be a JSON object at the top level. */ + json?: Record; + /** URL to redirect the client to. */ + redirect?: string; + /** Whether `redirect` is an absolute URL. @default false */ + absoluteRedirect?: boolean; +} + +/** + * Registers an action: an HTTP endpoint bound to a content node, invoked through + * `..do` URLs. + * + * ```ts + * registerAction({ name: "myModuleGreet", requiredMethods: ["GET"] }, ({ parameters, resource }) => ({ + * json: { + * greeting: `Hello ${parameters.who?.[0] ?? "world"}`, + * path: resource.getNode().getPath(), + * }, + * })); + * ``` + * + * Handlers run synchronously on a server thread and must return their result (no promises). + * + * @param declaration The action declaration; `name` is the URL-visible action name. + * @param handler Executes the action and returns the response to send. + */ +export const registerAction = ( + { name, requiredMethods, requireAuthenticatedUser, requiredPermission, requiredWorkspace }: ActionDeclaration, + handler: (context: ActionContext) => ActionResult | undefined, +): void => { + server.registry.add("action", name, { + ...(requiredMethods !== undefined && { requiredMethods: requiredMethods.join(",") }), + ...(requireAuthenticatedUser !== undefined && { requireAuthenticatedUser }), + ...(requiredPermission !== undefined && { requiredPermission }), + ...(requiredWorkspace !== undefined && { requiredWorkspace }), + // Raw adapter invoked by the Java bridge (ActionRegistrar.ActionBridge) with the + // Action#doExecute arguments; returns {statusCode, json?: string, redirect?, absoluteRedirect?} + // with json pre-stringified. Keep both shapes in sync. + doExecute: ( + request: HttpServletRequest, + renderContext: RenderContext, + resource: Resource, + session: JCRSessionWrapper, + javaParameters: JavaMap>, + urlResolver: URLResolver, + ) => { + const result = handler({ + parameters: toJsParameters(javaParameters), + renderContext, + resource, + session, + request, + urlResolver, + }); + if (!result) return { statusCode: 200 }; + return { + statusCode: result.statusCode ?? 200, + ...(result.json !== undefined && { json: JSON.stringify(result.json) }), + ...(result.redirect !== undefined && { + redirect: result.redirect, + absoluteRedirect: result.absoluteRedirect ?? false, + }), + }; + }, + }); + console.debug(`Registered action: ${name}`); +}; + +/** Converts the Java Map> of request parameters into a plain JS object. */ +const toJsParameters = (javaParameters: JavaMap>): Record => { + const parameters: Record = {}; + if (javaParameters) { + // keySet() is not part of the generated Map typing but is available at runtime + const keys = (javaParameters as unknown as { keySet(): { iterator(): Iterator } }) + .keySet() + .iterator(); + while (keys.hasNext()) { + const key = keys.next(); + const values = javaParameters.get(key); + const jsValues: string[] = []; + if (values) { + for (let i = 0; i < values.size(); i++) { + jsValues.push(values.get(i)); + } + } + parameters[key] = jsValues; + } + } + return parameters; +}; + +/** Minimal typing of a java.util.Iterator, which is not part of the generated Map typing. */ +interface Iterator { + hasNext(): boolean; + next(): T; +} diff --git a/javascript-modules-library/src/index.ts b/javascript-modules-library/src/index.ts index 474cf446..8dd34dd8 100644 --- a/javascript-modules-library/src/index.ts +++ b/javascript-modules-library/src/index.ts @@ -12,6 +12,12 @@ export { Area } from "./components/Area.js"; // Declaration and registration export { jahiaComponent } from "./framework/jahiaComponent.js"; +export { + registerAction, + type ActionDeclaration, + type ActionContext, + type ActionResult, +} from "./framework/registerAction.js"; export { registerChoiceListInitializer, type ChoiceListValue, diff --git a/tests/cypress/e2e/ui/actionTest.cy.ts b/tests/cypress/e2e/ui/actionTest.cy.ts new file mode 100644 index 00000000..1bf48fdb --- /dev/null +++ b/tests/cypress/e2e/ui/actionTest.cy.ts @@ -0,0 +1,93 @@ +import { addNode, publishAndWaitJobEnding } from "@jahia/cypress"; +import { addSimplePage } from "../../utils/helpers"; +import { GENERIC_SITE_KEY } from "../../support/constants"; + +const pageName = "testJsActions"; +const nodePath = `/sites/${GENERIC_SITE_KEY}/home/${pageName}/pagecontent/test`; +const actionUrl = (action: string, workspace = "live") => + `/cms/render/${workspace}/en${nodePath}.${action}.do`; + +describe("JS actions", () => { + before("Create and publish test content", () => { + cy.login(); + addSimplePage(`/sites/${GENERIC_SITE_KEY}/home`, pageName, pageName, "en", "simple", [ + { + name: "pagecontent", + primaryNodeType: "jnt:contentList", + }, + ]).then(() => { + addNode({ + parentPathOrId: `/sites/${GENERIC_SITE_KEY}/home/${pageName}/pagecontent`, + name: "test", + primaryNodeType: "javascriptExample:testGetNodeProps", + }); + }); + publishAndWaitJobEnding(`/sites/${GENERIC_SITE_KEY}/home/${pageName}`); + cy.logout(); + }); + + it("executes a GET action and returns JSON", () => { + cy.request(`${actionUrl("testJsActionGet")}?echo=hello`).then((response) => { + expect(response.status).to.equal(200); + expect(response.body.echo).to.equal("hello"); + expect(response.body.path).to.equal(nodePath); + }); + }); + + it("executes a CSRF-whitelisted POST action with form parameters", () => { + cy.request({ + method: "POST", + url: actionUrl("testJsActionPost"), + form: true, + body: { payload: "some-content" }, + }).then((response) => { + expect(response.status).to.equal(201); + expect(response.body.received).to.equal("some-content"); + }); + }); + + it("rejects guests on an action requiring authentication", () => { + cy.request({ url: actionUrl("testJsActionAuth"), failOnStatusCode: false }).then((response) => { + expect(response.status).to.equal(401); + }); + }); + + it("executes an authenticated action for a logged-in user", () => { + cy.login(); + cy.request(actionUrl("testJsActionAuth", "default")).then((response) => { + expect(response.status).to.equal(200); + expect(response.body.user).to.equal("root"); + }); + cy.logout(); + }); + + it("sends redirects", () => { + cy.request({ url: actionUrl("testJsActionRedirect"), followRedirect: false }).then((response) => { + expect(response.status).to.equal(302); + expect(response.headers.location).to.contain("/redirected-target"); + }); + }); + + it("rejects disallowed HTTP methods", () => { + // testJsActionPost only allows POST + cy.request({ url: actionUrl("testJsActionPost"), failOnStatusCode: false }).then((response) => { + expect(response.status).to.be.gte(400); + }); + }); + + it("still executes actions after a module redeploy invalidates the JS context pool", () => { + // the bridge re-resolves the JS function from the live registry on every call + cy.login(); + cy.runProvisioningScript({ + script: { + fileContent: '- enable: "javascript-modules-engine-test-module"', + type: "application/yaml", + }, + }); + cy.logout(); + cy.request(`${actionUrl("testJsActionGet")}?echo=after-redeploy`).then((response) => { + expect(response.status).to.equal(200); + expect(response.body.echo).to.equal("after-redeploy"); + }); + }); +}); From 3c93a15e05ee1c7b5f2f21825c58a45416f037f6 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Tue, 21 Jul 2026 20:25:53 +0200 Subject: [PATCH 05/36] feat: JS modules can declare server-side node validators - New registrars/validation package: a single JSNodeValidator Bean Validation bean registered under the sentinel nt:base node type (exactly-once execution per changed node per save; no clobbering of Java validators on real node types), carrying four repeatable class-level @JSValidation constraints that mirror Jahia's default/advanced/skip-on-import validation phases (ADR-0005). - JSValidationConstraintValidator dispatches to JS validators via the ref-counted NodeValidatorRegistrar (volatile snapshot gate before any GraalVM entry, ownership-checked platform (un)registration, fail-closed on throwing validators). - Messages follow JahiaMessageInterpolator semantics: {resource.bundle.key} messages are localized through module resource bundles, anything else is verbatim; sub-2-character messages get a generic fallback (interpolator crash guard). - New registerNodeValidator() library API with locale (BCP-47) + raw Java escape hatch. - Bean Validation deps (api provided, hibernate-validator 6.2.0.Final at test scope); full-chain unit tests against real HV; registrar state-machine tests. - Test-module fixtures + CND type, Cypress spec (field/node-level, phases, verbatim messages, redeploy resilience), reference documentation. --- docs/3-reference/3-node-validators/README.md | 61 ++++ ...alidators-single-bean-validation-bridge.md | 2 +- jahia-test-module/settings/definitions.cnd | 5 + .../src/react/server/extensions/validators.ts | 44 +++ javascript-modules-engine-java/pom.xml | 23 ++ .../validation/JSNodeValidator.java | 53 ++++ .../registrars/validation/JSValidation.java | 66 +++++ .../JSValidationConstraintValidator.java | 99 +++++++ .../registrars/validation/JSViolation.java | 45 +++ .../validation/NodeValidatorRegistrar.java | 266 ++++++++++++++++++ .../JSNodeValidatorBeanValidationTest.java | 195 +++++++++++++ .../NodeValidatorRegistrarTest.java | 211 ++++++++++++++ .../src/framework/registerNodeValidator.ts | 98 +++++++ javascript-modules-library/src/index.ts | 6 + tests/cypress/e2e/ui/nodeValidatorTest.cy.ts | 119 ++++++++ 15 files changed, 1292 insertions(+), 1 deletion(-) create mode 100644 docs/3-reference/3-node-validators/README.md create mode 100644 jahia-test-module/src/react/server/extensions/validators.ts create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidator.java create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidation.java create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidationConstraintValidator.java create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSViolation.java create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrar.java create mode 100644 javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidatorBeanValidationTest.java create mode 100644 javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrarTest.java create mode 100644 javascript-modules-library/src/framework/registerNodeValidator.ts create mode 100644 tests/cypress/e2e/ui/nodeValidatorTest.cy.ts diff --git a/docs/3-reference/3-node-validators/README.md b/docs/3-reference/3-node-validators/README.md new file mode 100644 index 00000000..7a851b5a --- /dev/null +++ b/docs/3-reference/3-node-validators/README.md @@ -0,0 +1,61 @@ +--- +page: + $path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/node-validators + jcr:title: Server-Side Node Validators + j:templateName: documentation +content: + $subpath: document-area/content +--- + +Node validators run on the server every time a JCR session saves a node of a given type. Returning violations rejects the save and surfaces error messages in Content Editor — attached to a specific field or to the whole node. With JavaScript modules you can declare validators in JavaScript, without writing a Java module. + +## Declaring a validator + +Call `registerNodeValidator` at the top level of a server file (it registers the validator as a side effect at module startup, like `jahiaComponent`): + +```ts +import { registerNodeValidator } from "@jahia/javascript-modules-library"; + +registerNodeValidator({ nodeType: "mymodule:article" }, (node) => { + const email = node.getPropertyAsString("email"); + if (email && !email.includes("@")) { + return { message: "Please provide a valid email address", propertyName: "email" }; + } +}); +``` + +The callback receives the `JCRNodeWrapper` being saved and returns: + +- **nothing** — the node is valid, +- **one violation** or **an array of violations** — the save is rejected. + +A violation is `{ message, propertyName? }`: with `propertyName`, the message is shown on that field in Content Editor; without it, it is shown as a node-level error. + +## Declaration options + +| Option | Description | +|--------|-------------| +| `nodeType` | Node type (primary or mixin) the validator applies to, matched with `isNodeType()`. | +| `name` | Distinguishes several validators on the same node type in one module. Default `"default"`. | +| `skipOnImport` | Skip this validator during content imports. Default `false`. | +| `advanced` | Run in the advanced phase, which only runs once **all** default-phase validators passed. Default `false`. | + +The two phases mirror Jahia's Java validator groups: default-phase violations suppress the advanced phase entirely (advanced checks can assume basic integrity). + +## Localizing messages + +Messages of the form `{my.bundle.key}` (the whole message being a single `{…}` reference) are resolved by Jahia against the deployed resource bundles, in the editor's UI locale — the same mechanism Java validators use. Ship the keys in your module's `settings/resources/*.properties` bundles: + +```ts +return { message: "{mymodule.validation.email.invalid}", propertyName: "email" }; +``` + +Any other message is displayed verbatim. Alternatively, resolve the text yourself in the callback using `context.locale` (the saving session's locale as a BCP-47 tag, possibly null). + +## Good to know + +- **Never call `session.save()` inside a validator** — it would recurse into validation. +- **Keep validators fast**: they run synchronously on every matching session save (editing, APIs, publication-driven saves). +- **i18n properties**: Jahia silently drops violations attached to internationalized properties when the saving session has no locale; return a node-level violation as a fallback if that matters for your check. +- **Failure policy**: a validator that throws fails the save with a generic node-level message (fail closed) and logs the error with the validator key; a returned violation without a string `message` is logged and ignored. +- **GraphQL/API saves** are validated too — the violation messages appear in the mutation errors. diff --git a/docs/adr/0005-js-node-validators-single-bean-validation-bridge.md b/docs/adr/0005-js-node-validators-single-bean-validation-bridge.md index 3d00995a..2ed05376 100644 --- a/docs/adr/0005-js-node-validators-single-bean-validation-bridge.md +++ b/docs/adr/0005-js-node-validators-single-bean-validation-bridge.md @@ -36,7 +36,7 @@ Chosen option: **1 — single bean under sentinel `nt:base`.** * Since every node `isNodeType("nt:base")` and the map holds exactly one JS entry, core instantiates the bean **exactly once per changed node per save** — every JS validator runs exactly once per phase, by construction. No dedup logic exists because none is needed. * The `ConstraintValidator` obtains the engine registrar via OSGi lookup (`BundleUtils.getOsgiService`, the repo's established pattern; `null` → no-op) and dispatches: a fast volatile snapshot gate (`Mode → Set`, checked with `node.isNodeType`) avoids entering GraalVM for unaffected nodes; matching entries are then re-resolved from the live context registry and executed inside `doWithContext`. * Violations are built programmatically: `disableDefaultConstraintViolation()` + `buildConstraintViolationWithTemplate(escapedMessage)`, with `.addPropertyNode(propertyName)` for field-level errors or none for node-level (blank path). The annotation deliberately has **no `propertyName()` attribute**, so core falls back to the per-violation property path. -* **Message escaping**: user messages are escaped (`\` → `\\`, `{` → `\{`, `}` → `\}`, `$` → `\$`, in that order) before becoming templates. On the platform's Hibernate Validator 6.2.0.Final, EL is off for custom violations, but `{…}` interpolation is active and an unbalanced brace throws; escaping `$` is defense-in-depth against future EL-default changes. Messages are therefore literals — resource-bundle template syntax does not apply; localization happens in the JS callback. +* **Message semantics**: Jahia's JCR validator factory (`applicationcontext-jcr.xml`) replaces Hibernate Validator's standard interpolation with `JahiaMessageInterpolator`, which performs **no EL and no `{…}` parameter parsing**. It strips the first and last character of the template and looks the remainder up as a resource-bundle key (ValidationMessages, then every deployed module's bundle, then Jahia internal messages, in the current UI locale); unresolved templates are returned **verbatim**. Consequently: no escaping is applied (it would leak backslashes); a message of exactly `{my.bundle.key}` form is localized through resource bundles — the same i18n mechanism Java validators use — and any other message is displayed as-is. The bridge guards one interpolator edge case: messages shorter than 2 characters (which would crash `substring(1, length-1)`) are replaced by a generic fallback. * **Lifecycle**: the registrar ref-counts declared validators across JS bundles; it calls `addValidator("nt:base", JSNodeValidator.class)` on 0→1 and `removeValidator("nt:base")` on 1→0 **only after verifying the registered constructor's declaring class is ours** (never delete a foreign validator; WARN in both collision directions). * **Error policy**: a *throwing* JS validator fails the save with a generic node-level violation (fail-closed, mirroring Java validator behavior; loud ERROR log with the validator key). A *malformed returned violation* (missing/non-string message) is logged and skipped — a shape bug must not brick every content save on the platform. diff --git a/jahia-test-module/settings/definitions.cnd b/jahia-test-module/settings/definitions.cnd index b99b5f9f..3a7ef7e0 100644 --- a/jahia-test-module/settings/definitions.cnd +++ b/jahia-test-module/settings/definitions.cnd @@ -159,3 +159,8 @@ [javascriptExample:testChoicelistInitializer] > jnt:content, javascriptExampleMix:javascriptExampleComponent, mix:title - color (string, choicelist[testColorsInitializer]) - colorWithParam (string, choicelist[testColorsInitializer='warm']) + +[javascriptExample:testValidation] > jnt:content, javascriptExampleMix:javascriptExampleComponent, mix:title + - email (string) + - score (long) + - i18nText (string) internationalized diff --git a/jahia-test-module/src/react/server/extensions/validators.ts b/jahia-test-module/src/react/server/extensions/validators.ts new file mode 100644 index 00000000..4be0735c --- /dev/null +++ b/jahia-test-module/src/react/server/extensions/validators.ts @@ -0,0 +1,44 @@ +import { registerNodeValidator } from "@jahia/javascript-modules-library"; + +/** + * Test fixtures for JS-declared node validators on javascriptExample:testValidation. + * + * Exercises: field-level violations, node-level violations, message pass-through with special + * characters, the advanced phase (only runs when the default phase passes), and skipOnImport. + */ + +const NODE_TYPE = "javascriptExample:testValidation"; + +// default phase: email format (field-level), node-level probe, special-characters probe +registerNodeValidator({ nodeType: NODE_TYPE }, (node) => { + const email = node.getPropertyAsString("email"); + if (!email) return undefined; + + if (email === "node-level-probe") { + return { message: "This content is inconsistent (node-level probe)" }; + } + if (email === "escaping-probe") { + // must survive verbatim: braces, EL-lookalike, backslash + return { message: "lone { brace, ${7*7}, back\\slash and {jcr:title}", propertyName: "email" }; + } + if (!email.includes("@")) { + return { message: "Please provide a valid email address", propertyName: "email" }; + } + return undefined; +}); + +// advanced phase: only runs once the default phase passed +registerNodeValidator({ nodeType: NODE_TYPE, name: "score-range", advanced: true }, (node) => { + if (node.hasProperty("score") && node.getProperty("score").getLong() > 100) { + return { message: "Score must be at most 100 (advanced phase)", propertyName: "score" }; + } + return undefined; +}); + +// skipped during content imports +registerNodeValidator({ nodeType: NODE_TYPE, name: "skip-on-import", skipOnImport: true }, (node) => { + if (node.getPropertyAsString("email") === "import-probe") { + return { message: "Rejected outside of imports (skip-on-import probe)", propertyName: "email" }; + } + return undefined; +}); diff --git a/javascript-modules-engine-java/pom.xml b/javascript-modules-engine-java/pom.xml index a338198e..77154c2a 100644 --- a/javascript-modules-engine-java/pom.xml +++ b/javascript-modules-engine-java/pom.xml @@ -116,6 +116,13 @@ 20231013 provided + + + javax.validation + validation-api + 2.0.1.Final + provided + org.jahia.server jahia-impl @@ -234,6 +241,19 @@ 2.6 test + + + org.hibernate.validator + hibernate-validator + 6.2.0.Final + test + + + org.glassfish + javax.el + 3.0.0 + test + @@ -346,6 +366,9 @@ org.graalvm.js:js commons-lang:commons-lang + + org.hibernate.validator:hibernate-validator + org.glassfish:javax.el org.apache.jackrabbit:jackrabbit-spi-commons org.jboss.spec.javax.servlet:jboss-servlet-api_3.1_spec diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidator.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidator.java new file mode 100644 index 00000000..8e8e2f4d --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidator.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +import org.jahia.services.content.JCRNodeWrapper; +import org.jahia.services.content.decorator.validation.AdvancedGroup; +import org.jahia.services.content.decorator.validation.AdvancedSkipOnImportGroup; +import org.jahia.services.content.decorator.validation.DefaultSkipOnImportGroup; +import org.jahia.services.content.decorator.validation.JCRNodeValidator; + +/** + * The single Bean Validation bean bridging all JavaScript-declared node validators. + * + *

It is registered once, for the sentinel node type {@code nt:base} (see + * {@link NodeValidatorRegistrar}), so Jahia core instantiates and validates it exactly once per changed + * node per save — node-type matching and dispatch to the JS validators happen in + * {@link JSValidationConstraintValidator}/{@link NodeValidatorRegistrar}. Registering it under each + * JS-declared node type instead would run every matching JS validator once per matching node type, + * producing duplicate violations. + * + *

The four repeated class-level constraints mirror Jahia's validation phases: on a normal save, core + * validates with groups (Default, DefaultSkipOnImportGroup) and then — only if that passed — with + * (AdvancedGroup, AdvancedSkipOnImportGroup); during imports, the SkipOnImport groups are omitted. + */ +@JSValidation(mode = JSValidation.Mode.DEFAULT) +@JSValidation(mode = JSValidation.Mode.DEFAULT_SKIP_ON_IMPORT, groups = DefaultSkipOnImportGroup.class) +@JSValidation(mode = JSValidation.Mode.ADVANCED, groups = AdvancedGroup.class) +@JSValidation(mode = JSValidation.Mode.ADVANCED_SKIP_ON_IMPORT, groups = AdvancedSkipOnImportGroup.class) +public class JSNodeValidator implements JCRNodeValidator { + + private final JCRNodeWrapper node; + + public JSNodeValidator(JCRNodeWrapper node) { + this.node = node; + } + + public JCRNodeWrapper getNode() { + return node; + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidation.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidation.java new file mode 100644 index 00000000..5a8247f5 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidation.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Class-level constraint carried by {@link JSNodeValidator}, dispatching to JavaScript-declared node + * validators. One annotation instance exists per Jahia validation phase combination (see {@link Mode}), + * with matching Bean Validation groups, so that Jahia's group orchestration during session save applies + * to JS validators exactly as it does to Java ones. + * + *

This annotation intentionally has no {@code propertyName()} attribute: Jahia core then derives the + * property from each violation's property path, which the constraint validator sets per violation. + */ +@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Repeatable(JSValidation.List.class) +@Constraint(validatedBy = JSValidationConstraintValidator.class) +public @interface JSValidation { + + String message() default ""; + + Class[] groups() default {}; + + Class[] payload() default {}; + + /** The Jahia validation phase this constraint instance covers. */ + Mode mode(); + + enum Mode { + /** First validation phase, also enforced during imports. */ + DEFAULT, + /** First validation phase, skipped during imports. */ + DEFAULT_SKIP_ON_IMPORT, + /** Second validation phase (runs only if the first one passed), also enforced during imports. */ + ADVANCED, + /** Second validation phase, skipped during imports. */ + ADVANCED_SKIP_ON_IMPORT + } + + @Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @interface List { + JSValidation[] value(); + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidationConstraintValidator.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidationConstraintValidator.java new file mode 100644 index 00000000..5dcb4380 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidationConstraintValidator.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +import org.apache.commons.lang3.StringUtils; +import org.jahia.osgi.BundleUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.List; +import java.util.function.Supplier; + +/** + * Dispatches {@link JSValidation} constraints to the JavaScript node validators registered for the + * current validation phase, and reports their violations programmatically — with a property node for + * field-level errors (Jahia core maps a resolvable property path to a field error in the editing UI, and + * a blank path to a node-level error). + * + *

Message templates are handed to Jahia's {@code JahiaMessageInterpolator}, which resolves messages of + * the form {resource.bundle.key} against deployed resource bundles and returns any other + * message verbatim (no EL, no parameter interpolation). Messages shorter than 2 characters would crash + * that interpolator and are replaced by a generic fallback. + */ +public class JSValidationConstraintValidator implements ConstraintValidator { + + private static final Logger logger = LoggerFactory.getLogger(JSValidationConstraintValidator.class); + + /** + * Test seam; the production default resolves the registrar service per call, which is cheap at + * validation frequency and stays correct across engine redeploys. + */ + static Supplier registrarSupplier = + () -> BundleUtils.getOsgiService(NodeValidatorRegistrar.class, null); + + private JSValidation.Mode mode; + + @Override + public void initialize(JSValidation constraintAnnotation) { + this.mode = constraintAnnotation.mode(); + } + + @Override + public boolean isValid(JSNodeValidator bean, ConstraintValidatorContext context) { + NodeValidatorRegistrar registrar; + try { + registrar = registrarSupplier.get(); + } catch (Exception e) { + logger.debug("JS node validator registrar is not available, skipping JS validation", e); + return true; + } + if (registrar == null) { + // engine stopped or redeploying: nothing to validate against + return true; + } + + List violations = registrar.collectViolations(bean.getNode(), mode); + if (violations.isEmpty()) { + return true; + } + + context.disableDefaultConstraintViolation(); + for (JSViolation violation : violations) { + ConstraintValidatorContext.ConstraintViolationBuilder builder = context + .buildConstraintViolationWithTemplate(sanitizeMessage(violation.getMessage(), violation.getValidatorKey())); + if (StringUtils.isNotBlank(violation.getPropertyName())) { + // property-level path -> field-level error in the editing UI + builder.addPropertyNode(violation.getPropertyName()).addConstraintViolation(); + } else { + // class-level violation -> blank path -> node-level error + builder.addConstraintViolation(); + } + } + return false; + } + + static String sanitizeMessage(String message, String validatorKey) { + if (message == null || message.trim().length() < 2) { + logger.warn("JS node validator '{}' returned a blank violation message, using a generic one", + validatorKey); + return "Invalid content"; + } + return message; + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSViolation.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSViolation.java new file mode 100644 index 00000000..c80f8054 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSViolation.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +/** + * A violation reported by a JavaScript node validator: a message, an optional property name (for + * field-level errors in the editing UI) and the key of the reporting validator. + */ +public final class JSViolation { + + private final String message; + private final String propertyName; + private final String validatorKey; + + public JSViolation(String message, String propertyName, String validatorKey) { + this.message = message; + this.propertyName = propertyName; + this.validatorKey = validatorKey; + } + + public String getMessage() { + return message; + } + + public String getPropertyName() { + return propertyName; + } + + public String getValidatorKey() { + return validatorKey; + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrar.java new file mode 100644 index 00000000..afb9868a --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrar.java @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +import org.graalvm.polyglot.Value; +import org.graalvm.polyglot.proxy.ProxyObject; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.jahia.modules.javascript.modules.engine.registrars.Registrar; +import org.jahia.services.content.JCRNodeWrapper; +import org.jahia.services.content.JCRStoreService; +import org.osgi.framework.Bundle; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.jcr.RepositoryException; +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Bridges JavaScript registry entries of type {@code node-validator} to Jahia's JCR save validation. + * + *

Unlike the OSGi-service-publishing registrars, Jahia consumes node validators through a global + * {@code nodeType -> validator class} map ({@link JCRStoreService#addValidator}) that allows a single + * validator class per node type, removed unconditionally by node type. This registrar therefore + * registers the single {@link JSNodeValidator} bridge class under the sentinel node type + * {@code nt:base} while any JS validator exists (so core instantiates it exactly once per changed node + * per save), performs all node-type matching itself, and removes the bridge — after an ownership check — + * only when the last JS validator is gone. + */ +@Component(service = {Registrar.class, NodeValidatorRegistrar.class}, immediate = true) +public class NodeValidatorRegistrar implements Registrar { + + public static final String REGISTRY_TYPE = "node-validator"; + static final String SENTINEL_NODE_TYPE = "nt:base"; + + private static final Logger logger = LoggerFactory.getLogger(NodeValidatorRegistrar.class); + + private GraalVMEngine graalVMEngine; + + /** Declared validators per bundle; guarded by {@code this}. */ + private final Map> declaredByBundle = new HashMap<>(); + /** Immutable fast gate read by validation threads without locking. */ + private volatile Map> declaredNodeTypesByMode = Collections.emptyMap(); + /** Guarded by {@code this}. */ + private boolean bridgeRegistered; + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + public void setGraalVMEngine(GraalVMEngine graalVMEngine) { + this.graalVMEngine = graalVMEngine; + } + + @Override + public void register(Bundle bundle) { + List> entries = graalVMEngine.doWithContext(contextProvider -> { + Map filter = new HashMap<>(); + filter.put("type", REGISTRY_TYPE); + filter.put("bundleKey", bundle.getSymbolicName()); + return contextProvider.getRegistry().find(filter); + }); + List declared = new ArrayList<>(); + for (Map entry : entries) { + Object nodeType = entry.get("nodeType"); + if (nodeType == null) { + logger.warn("Ignoring JS node validator '{}' of bundle {}: no nodeType declared", + entry.get("key"), bundle.getSymbolicName()); + continue; + } + declared.add(new DeclaredValidator(nodeType.toString(), modeOf(entry))); + } + synchronized (this) { + declaredByBundle.put(bundle, declared); + rebuildSnapshotAndBridge(); + } + } + + @Override + public void unregister(Bundle bundle) { + synchronized (this) { + declaredByBundle.remove(bundle); + rebuildSnapshotAndBridge(); + } + } + + @Deactivate + public void deactivate() { + synchronized (this) { + declaredByBundle.clear(); + rebuildSnapshotAndBridge(); + } + } + + /** Called under lock. */ + private void rebuildSnapshotAndBridge() { + Map> snapshot = new EnumMap<>(JSValidation.Mode.class); + for (List declared : declaredByBundle.values()) { + for (DeclaredValidator validator : declared) { + snapshot.computeIfAbsent(validator.mode, mode -> new HashSet<>()).add(validator.nodeType); + } + } + declaredNodeTypesByMode = Collections.unmodifiableMap(snapshot); + + boolean needed = !snapshot.isEmpty(); + if (needed && !bridgeRegistered) { + Constructor existing = getRegisteredPlatformValidator(); + if (existing != null && !JSNodeValidator.class.equals(existing.getDeclaringClass())) { + logger.warn("A validator ({}) is already registered for node type {}; it will be replaced " + + "by the JavaScript modules validator bridge (the platform allows a single validator " + + "class per node type)", existing.getDeclaringClass().getName(), SENTINEL_NODE_TYPE); + } + addPlatformValidator(); + bridgeRegistered = true; + } else if (!needed && bridgeRegistered) { + Constructor current = getRegisteredPlatformValidator(); + if (current != null && JSNodeValidator.class.equals(current.getDeclaringClass())) { + removePlatformValidator(); + } else if (current != null) { + logger.warn("Not removing the validator registered for node type {}: it is owned by {}", + SENTINEL_NODE_TYPE, current.getDeclaringClass().getName()); + } + bridgeRegistered = false; + } + } + + // JCRStoreService interactions isolated as seams for unit tests + + protected Constructor getRegisteredPlatformValidator() { + return JCRStoreService.getInstance().getValidators().get(SENTINEL_NODE_TYPE); + } + + protected void addPlatformValidator() { + JCRStoreService.getInstance().addValidator(SENTINEL_NODE_TYPE, JSNodeValidator.class); + } + + protected void removePlatformValidator() { + JCRStoreService.getInstance().removeValidator(SENTINEL_NODE_TYPE); + } + + /** + * Runs the JS validators declared for the given phase against the node and returns their violations. + * Called by {@link JSValidationConstraintValidator} on every session save of any node; the volatile + * snapshot gate avoids entering GraalVM when no declared node type matches. + */ + public List collectViolations(JCRNodeWrapper node, JSValidation.Mode mode) { + if (node == null) { + return Collections.emptyList(); + } + Set candidateTypes = declaredNodeTypesByMode.getOrDefault(mode, Collections.emptySet()); + if (candidateTypes.isEmpty() || candidateTypes.stream().noneMatch(type -> isNodeTypeSafe(node, type))) { + return Collections.emptyList(); + } + + return graalVMEngine.doWithContext(contextProvider -> { + List violations = new ArrayList<>(); + Map filter = new HashMap<>(); + filter.put("type", REGISTRY_TYPE); + for (Map entry : contextProvider.getRegistry().find(filter)) { + if (modeOf(entry) != mode) { + continue; + } + Object nodeType = entry.get("nodeType"); + if (nodeType == null || !isNodeTypeSafe(node, nodeType.toString())) { + continue; + } + String key = String.valueOf(entry.get("key")); + try { + Map jsContext = new HashMap<>(); + jsContext.put("locale", getSessionLocale(node)); + Value result = Value.asValue(entry.get("validate")).execute(node, ProxyObject.fromMap(jsContext)); + appendViolations(violations, result, key); + } catch (Exception e) { + // fail closed: a broken validator must not let invalid content through + logger.error("JS node validator '{}' failed to execute", key, e); + violations.add(new JSViolation("The content could not be validated (" + key + ")", null, key)); + } + } + return violations; + }); + } + + static JSValidation.Mode modeOf(Map entry) { + boolean advanced = Boolean.TRUE.equals(entry.get("advanced")); + boolean skipOnImport = Boolean.TRUE.equals(entry.get("skipOnImport")); + if (advanced) { + return skipOnImport ? JSValidation.Mode.ADVANCED_SKIP_ON_IMPORT : JSValidation.Mode.ADVANCED; + } + return skipOnImport ? JSValidation.Mode.DEFAULT_SKIP_ON_IMPORT : JSValidation.Mode.DEFAULT; + } + + /** Accepts undefined/null (no violations), a single violation object, or an array of them. */ + static void appendViolations(List violations, Value result, String key) { + if (result == null || result.isNull()) { + return; + } + if (result.hasArrayElements()) { + for (long i = 0; i < result.getArraySize(); i++) { + appendViolation(violations, result.getArrayElement(i), key); + } + } else { + appendViolation(violations, result, key); + } + } + + private static void appendViolation(List violations, Value item, String key) { + Value message = item.hasMembers() ? item.getMember("message") : null; + if (message == null || message.isNull() || !message.isString()) { + logger.warn("JS node validator '{}' returned a violation without a string message, skipping it", key); + return; + } + Value propertyName = item.getMember("propertyName"); + violations.add(new JSViolation(message.asString(), + propertyName != null && propertyName.isString() ? propertyName.asString() : null, key)); + } + + private static Locale getSessionLocale(JCRNodeWrapper node) { + try { + return node.getSession().getLocale(); + } catch (RepositoryException e) { + logger.debug("Unable to read the session locale for validation", e); + return null; + } + } + + private static boolean isNodeTypeSafe(JCRNodeWrapper node, String nodeType) { + try { + return node.isNodeType(nodeType); + } catch (RepositoryException e) { + logger.warn("Unable to check node type {} during JS validation", nodeType, e); + return false; + } + } + + private static final class DeclaredValidator { + private final String nodeType; + private final JSValidation.Mode mode; + + private DeclaredValidator(String nodeType, JSValidation.Mode mode) { + this.nodeType = nodeType; + this.mode = mode; + } + } +} diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidatorBeanValidationTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidatorBeanValidationTest.java new file mode 100644 index 00000000..2b24a546 --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidatorBeanValidationTest.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +import org.jahia.services.content.JCRNodeWrapper; +import org.jahia.services.content.decorator.validation.AdvancedGroup; +import org.jahia.services.content.decorator.validation.DefaultSkipOnImportGroup; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.validation.ConstraintViolation; +import javax.validation.MessageInterpolator; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import javax.validation.groups.Default; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.function.Supplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Exercises the whole Bean Validation chain of the JS validator bridge against a real Hibernate + * Validator (same version as the platform): constraint discovery on {@link JSNodeValidator}, group + * orchestration per {@link JSValidation.Mode}, programmatic violation building (property-level vs + * node-level paths) and message pass-through. + * + *

The interpolator is a pass-through, mimicking Jahia's {@code JahiaMessageInterpolator} behavior for + * messages that do not match a resource bundle key (it returns unresolved templates verbatim and never + * applies EL or parameter interpolation). + */ +public class JSNodeValidatorBeanValidationTest { + + private static ValidatorFactory factory; + private static Validator validator; + + /** Canned violations returned by the fake registrar, per requested mode. */ + private FakeRegistrar fakeRegistrar; + private Supplier previousSupplier; + + private static class FakeRegistrar extends NodeValidatorRegistrar { + private final List defaultViolations = new ArrayList<>(); + private final List defaultSkipOnImportViolations = new ArrayList<>(); + private final List advancedViolations = new ArrayList<>(); + + @Override + public List collectViolations(JCRNodeWrapper node, JSValidation.Mode mode) { + switch (mode) { + case DEFAULT: + return defaultViolations; + case DEFAULT_SKIP_ON_IMPORT: + return defaultSkipOnImportViolations; + case ADVANCED: + return advancedViolations; + default: + return List.of(); + } + } + } + + @BeforeClass + public static void setUpFactory() { + factory = Validation.byDefaultProvider().configure() + .messageInterpolator(new MessageInterpolator() { + @Override + public String interpolate(String messageTemplate, Context context) { + return messageTemplate; + } + + @Override + public String interpolate(String messageTemplate, Context context, Locale locale) { + return messageTemplate; + } + }) + .buildValidatorFactory(); + validator = factory.getValidator(); + } + + @AfterClass + public static void tearDownFactory() { + factory.close(); + } + + @Before + public void setUp() { + fakeRegistrar = new FakeRegistrar(); + previousSupplier = JSValidationConstraintValidator.registrarSupplier; + JSValidationConstraintValidator.registrarSupplier = () -> fakeRegistrar; + } + + @After + public void tearDown() { + JSValidationConstraintValidator.registrarSupplier = previousSupplier; + } + + @Test + public void noViolationsMeansValid() { + assertTrue(validator.validate(new JSNodeValidator(null)).isEmpty()); + } + + @Test + public void propertyLevelViolationCarriesThePropertyPath() { + fakeRegistrar.defaultViolations.add(new JSViolation("Email is invalid", "email", "test")); + + Set> violations = validator.validate(new JSNodeValidator(null)); + + assertEquals(1, violations.size()); + ConstraintViolation violation = violations.iterator().next(); + assertEquals("Email is invalid", violation.getMessage()); + // Jahia core maps a resolvable property path to a field-level error in the editing UI + assertEquals("email", violation.getPropertyPath().toString()); + } + + @Test + public void nodeLevelViolationHasABlankPath() { + fakeRegistrar.defaultViolations.add(new JSViolation("Node is inconsistent", null, "test")); + + Set> violations = validator.validate(new JSNodeValidator(null)); + + assertEquals(1, violations.size()); + // Jahia core maps a blank property path to a node-level error + assertEquals("", violations.iterator().next().getPropertyPath().toString()); + } + + @Test + public void messagesPassThroughVerbatimIncludingSpecialCharacters() { + String nasty = "lone { brace, ${7*7}, back\\slash and {jcr:title}"; + fakeRegistrar.defaultViolations.add(new JSViolation(nasty, "email", "test")); + + Set> violations = validator.validate(new JSNodeValidator(null)); + + assertEquals(nasty, violations.iterator().next().getMessage()); + } + + @Test + public void blankMessagesAreReplacedByAGenericFallback() { + fakeRegistrar.defaultViolations.add(new JSViolation(" ", null, "test")); + + Set> violations = validator.validate(new JSNodeValidator(null)); + + assertEquals("Invalid content", violations.iterator().next().getMessage()); + } + + @Test + public void groupOrchestrationMatchesJahiaPhases() { + fakeRegistrar.defaultViolations.add(new JSViolation("default phase", null, "test")); + fakeRegistrar.defaultSkipOnImportViolations.add(new JSViolation("default skip-on-import phase", null, "test")); + fakeRegistrar.advancedViolations.add(new JSViolation("advanced phase", null, "test")); + + // normal save, first phase: Default + DefaultSkipOnImportGroup (what Jahia core requests) + Set> firstPhase = + validator.validate(new JSNodeValidator(null), Default.class, DefaultSkipOnImportGroup.class); + assertEquals(2, firstPhase.size()); + + // import, first phase: Default only -> the skip-on-import validator does not run + Set> importPhase = + validator.validate(new JSNodeValidator(null), Default.class); + assertEquals(1, importPhase.size()); + assertEquals("default phase", importPhase.iterator().next().getMessage()); + + // second phase: AdvancedGroup + Set> advancedPhase = + validator.validate(new JSNodeValidator(null), AdvancedGroup.class); + assertEquals(1, advancedPhase.size()); + assertEquals("advanced phase", advancedPhase.iterator().next().getMessage()); + } + + @Test + public void missingRegistrarMeansValid() { + JSValidationConstraintValidator.registrarSupplier = () -> null; + fakeRegistrar.defaultViolations.add(new JSViolation("should not surface", null, "test")); + + assertTrue(validator.validate(new JSNodeValidator(null)).isEmpty()); + } +} diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrarTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrarTest.java new file mode 100644 index 00000000..c742512a --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrarTest.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.osgi.framework.Bundle; + +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class NodeValidatorRegistrarTest { + + private static Context jsContext; + + private TestableRegistrar registrar; + private List> registryEntries; + + /** Registrar with the JCRStoreService interactions replaced by an in-memory state. */ + private static class TestableRegistrar extends NodeValidatorRegistrar { + Constructor platformValidator; + int addCalls; + int removeCalls; + + @Override + protected Constructor getRegisteredPlatformValidator() { + return platformValidator; + } + + @Override + protected void addPlatformValidator() { + addCalls++; + platformValidator = JSNodeValidator.class.getConstructors()[0]; + } + + @Override + protected void removePlatformValidator() { + removeCalls++; + platformValidator = null; + } + } + + @BeforeClass + public static void setUpContext() { + jsContext = Context.newBuilder("js").build(); + } + + @AfterClass + public static void tearDownContext() { + jsContext.close(); + } + + @Before + @SuppressWarnings("unchecked") + public void setUp() { + GraalVMEngine engine = mock(GraalVMEngine.class); + registryEntries = new ArrayList<>(); + when(engine.doWithContext(any(Function.class))).thenAnswer(invocation -> registryEntries); + + registrar = new TestableRegistrar(); + registrar.setGraalVMEngine(engine); + } + + private Bundle bundle(String symbolicName) { + Bundle bundle = mock(Bundle.class); + when(bundle.getSymbolicName()).thenReturn(symbolicName); + return bundle; + } + + private Map entry(String key, String nodeType, boolean skipOnImport, boolean advanced) { + Map entry = new HashMap<>(); + entry.put("type", "node-validator"); + entry.put("key", key); + entry.put("nodeType", nodeType); + entry.put("skipOnImport", skipOnImport); + entry.put("advanced", advanced); + return entry; + } + + @Test + public void bridgeIsRegisteredOnFirstValidatorAndRemovedWithTheLastOne() { + Bundle bundleA = bundle("module-a"); + Bundle bundleB = bundle("module-b"); + + registryEntries.add(entry("a", "jnt:a", false, false)); + registrar.register(bundleA); + assertEquals(1, registrar.addCalls); + + registryEntries.clear(); + registryEntries.add(entry("b", "jnt:b", false, false)); + registrar.register(bundleB); + // still a single platform registration + assertEquals(1, registrar.addCalls); + + registrar.unregister(bundleA); + assertEquals(0, registrar.removeCalls); + + registrar.unregister(bundleB); + assertEquals(1, registrar.removeCalls); + assertNull(registrar.platformValidator); + } + + @Test + public void bundlesWithoutValidatorsDoNotRegisterTheBridge() { + registrar.register(bundle("module-without-validators")); + assertEquals(0, registrar.addCalls); + } + + @Test + public void aForeignPlatformValidatorIsNeverRemoved() throws Exception { + // simulate another module having clobbered the sentinel registration + registryEntries.add(entry("a", "jnt:a", false, false)); + Bundle bundleA = bundle("module-a"); + registrar.register(bundleA); + + Constructor foreign = String.class.getConstructor(); + registrar.platformValidator = foreign; + + registrar.unregister(bundleA); + assertEquals(0, registrar.removeCalls); + assertEquals(foreign, registrar.platformValidator); + } + + @Test + public void deactivateCleansUp() { + registryEntries.add(entry("a", "jnt:a", false, false)); + registrar.register(bundle("module-a")); + + registrar.deactivate(); + assertEquals(1, registrar.removeCalls); + } + + @Test + public void modesAreDerivedFromTheDeclarationFlags() { + assertEquals(JSValidation.Mode.DEFAULT, NodeValidatorRegistrar.modeOf(entry("k", "t", false, false))); + assertEquals(JSValidation.Mode.DEFAULT_SKIP_ON_IMPORT, NodeValidatorRegistrar.modeOf(entry("k", "t", true, false))); + assertEquals(JSValidation.Mode.ADVANCED, NodeValidatorRegistrar.modeOf(entry("k", "t", false, true))); + assertEquals(JSValidation.Mode.ADVANCED_SKIP_ON_IMPORT, NodeValidatorRegistrar.modeOf(entry("k", "t", true, true))); + } + + @Test + public void violationResultsAcceptAllDocumentedShapes() { + List violations = new ArrayList<>(); + + NodeValidatorRegistrar.appendViolations(violations, jsContext.eval("js", "undefined"), "test"); + NodeValidatorRegistrar.appendViolations(violations, jsContext.eval("js", "null"), "test"); + assertTrue(violations.isEmpty()); + + NodeValidatorRegistrar.appendViolations(violations, + jsContext.eval("js", "({message: 'single', propertyName: 'email'})"), "test"); + assertEquals(1, violations.size()); + assertEquals("single", violations.get(0).getMessage()); + assertEquals("email", violations.get(0).getPropertyName()); + + NodeValidatorRegistrar.appendViolations(violations, + jsContext.eval("js", "[{message: 'first'}, {message: 'second', propertyName: 'score'}]"), "test"); + assertEquals(3, violations.size()); + assertNull(violations.get(1).getPropertyName()); + assertEquals("score", violations.get(2).getPropertyName()); + + // malformed items (no string message) are skipped + NodeValidatorRegistrar.appendViolations(violations, + jsContext.eval("js", "[{propertyName: 'email'}, {message: 42}, 'not-an-object']"), "test"); + assertEquals(3, violations.size()); + } + + @Test + public void collectViolationsReturnsNothingWhenNoTypeMatchesTheMode() { + // no snapshot at all: the gate short-circuits before touching the engine + assertTrue(registrar.collectViolations(null, JSValidation.Mode.DEFAULT).isEmpty()); + } + + @Test + public void violationOfMissingMessageStringIsSkipped() { + List violations = new ArrayList<>(); + Value item = jsContext.eval("js", "({message: null})"); + NodeValidatorRegistrar.appendViolations(violations, item, "test"); + assertFalse(violations.stream().anyMatch(v -> v.getValidatorKey().equals("missing"))); + assertTrue(violations.isEmpty()); + } +} diff --git a/javascript-modules-library/src/framework/registerNodeValidator.ts b/javascript-modules-library/src/framework/registerNodeValidator.ts new file mode 100644 index 00000000..534906d7 --- /dev/null +++ b/javascript-modules-library/src/framework/registerNodeValidator.ts @@ -0,0 +1,98 @@ +import type { JCRNodeWrapper } from "org.jahia.services.content"; +import type { Locale } from "java.util"; + +/** + * `registerNodeValidator` calls are executed synchronously during module initialization. During this + * time, `bundleKey` is set to the symbolic name of the active bundle. + */ +declare const bundleKey: string; + +/** A violation reported by a node validator. */ +export interface NodeValidatorViolation { + /** + * The message shown to the editor. Either literal text, or a `{resource.bundle.key}` reference + * resolved by Jahia against the deployed resource bundles (in the editor's UI locale) — the same + * i18n mechanism used by Java validators. Any other text is displayed verbatim. + */ + message: string; + /** + * JCR property name (e.g. `"jcr:title"`) to attach the violation to a specific field in the + * editing UI; omit for a node-level violation. + */ + propertyName?: string; +} + +/** Context passed to a node validator callback. */ +export interface NodeValidatorContext { + /** + * BCP-47 language tag of the saving session's locale, or null when the save is not bound to a + * locale. Note that Jahia silently drops violations on internationalized properties when the + * session locale is null. + */ + locale: string | null; + /** Escape hatch: the raw Java objects. */ + java: { + locale: Locale | null; + }; +} + +/** Declaration of a node validator. */ +export interface NodeValidatorProps { + /** Node type (primary or mixin) this validator applies to, matched with `isNodeType()`. */ + nodeType: string; + /** + * Distinguishes several validators declared for the same node type in the same module. + * + * @default "default" + */ + name?: string; + /** Skip this validator during content imports. @default false */ + skipOnImport?: boolean; + /** + * Run this validator in the advanced phase, which only runs once all default-phase validators + * passed. @default false + */ + advanced?: boolean; +} + +/** + * Registers a server-side node validator, executed by Jahia on every session save of a node of the + * declared type. Returning one or more violations rejects the save and surfaces the messages in the + * editing UI (field-level when `propertyName` is set, node-level otherwise). + * + * ```ts + * registerNodeValidator({ nodeType: "mymodule:article" }, (node) => { + * const email = node.getPropertyAsString("email"); + * if (email && !email.includes("@")) { + * return { message: "Please provide a valid email address", propertyName: "email" }; + * } + * }); + * ``` + * + * Validators run synchronously on every matching save — keep them fast, and never call + * `session.save()` from a validator. + * + * @param props The validator declaration. + * @param validate Returns the violations (array, single violation, or nothing when valid). + */ +export const registerNodeValidator = ( + { nodeType, name = "default", skipOnImport = false, advanced = false }: NodeValidatorProps, + validate: ( + node: JCRNodeWrapper, + context: NodeValidatorContext, + ) => NodeValidatorViolation[] | NodeValidatorViolation | undefined, +): void => { + server.registry.add("node-validator", `${bundleKey}_node-validator_${nodeType}_${name}`, { + nodeType, + skipOnImport, + advanced, + // Raw adapter invoked by the Java bridge (NodeValidatorRegistrar) with the node and a context + // holding the raw session locale. Keep both shapes in sync. + validate: (node: JCRNodeWrapper, javaContext: { locale: Locale | null }) => + validate(node, { + locale: javaContext.locale ? javaContext.locale.toLanguageTag() : null, + java: { locale: javaContext.locale }, + }), + }); + console.debug(`Registered node validator for ${nodeType} (${name})`); +}; diff --git a/javascript-modules-library/src/index.ts b/javascript-modules-library/src/index.ts index 8dd34dd8..9b7d1a67 100644 --- a/javascript-modules-library/src/index.ts +++ b/javascript-modules-library/src/index.ts @@ -23,6 +23,12 @@ export { type ChoiceListValue, type ChoiceListInitializerContext, } from "./framework/registerChoiceListInitializer.js"; +export { + registerNodeValidator, + type NodeValidatorProps, + type NodeValidatorContext, + type NodeValidatorViolation, +} from "./framework/registerNodeValidator.js"; // Hooks export { useGQLQuery } from "./hooks/useGQLQuery.js"; diff --git a/tests/cypress/e2e/ui/nodeValidatorTest.cy.ts b/tests/cypress/e2e/ui/nodeValidatorTest.cy.ts new file mode 100644 index 00000000..96f9ac36 --- /dev/null +++ b/tests/cypress/e2e/ui/nodeValidatorTest.cy.ts @@ -0,0 +1,119 @@ +import { addNode, deleteNode } from "@jahia/cypress"; +import { addSimplePage } from "../../utils/helpers"; +import { GENERIC_SITE_KEY } from "../../support/constants"; + +const pageName = "testJsValidators"; +const parentPath = `/sites/${GENERIC_SITE_KEY}/home/${pageName}/pagecontent`; + +const ADD_NODE_MUTATION = ` + mutation addValidatedNode($parentPathOrId: String!, $name: String!, $properties: [InputJCRProperty!]) { + jcr { + addNode( + parentPathOrId: $parentPathOrId + name: $name + primaryNodeType: "javascriptExample:testValidation" + properties: $properties + ) { + uuid + } + } + } +`; + +/** Attempts to create a testValidation node and yields the raw apollo response (errors included). */ +const tryCreate = (name: string, properties: Array<{ name: string; value: string; language?: string }>) => + cy.apollo({ + mutation: ADD_NODE_MUTATION, + variables: { parentPathOrId: parentPath, name, properties }, + errorPolicy: "all", + }); + +const errorMessages = (response: { errors?: Array<{ message: string }> }): string => + (response.errors ?? []).map((error) => error.message).join(" | "); + +describe("JS node validators", () => { + before("Create test page", () => { + cy.login(); + addSimplePage(`/sites/${GENERIC_SITE_KEY}/home`, pageName, pageName, "en", "simple", [ + { + name: "pagecontent", + primaryNodeType: "jnt:contentList", + }, + ]); + cy.logout(); + }); + + beforeEach("Login", () => { + cy.login(); + }); + afterEach("Logout", () => { + cy.logout(); + }); + + it("accepts valid content", () => { + addNode({ + parentPathOrId: parentPath, + name: "valid", + primaryNodeType: "javascriptExample:testValidation", + properties: [ + { name: "email", value: "someone@example.org" }, + { name: "score", value: "50" }, + ], + }).then((response) => { + expect(response?.data?.jcr?.addNode?.uuid).to.exist; + deleteNode(`${parentPath}/valid`); + }); + }); + + it("rejects a save with a field-level violation", () => { + tryCreate("invalidEmail", [{ name: "email", value: "not-an-email" }]).then((response) => { + expect(errorMessages(response)).to.contain("Please provide a valid email address"); + }); + }); + + it("rejects a save with a node-level violation", () => { + tryCreate("nodeLevel", [{ name: "email", value: "node-level-probe" }]).then((response) => { + expect(errorMessages(response)).to.contain("This content is inconsistent (node-level probe)"); + }); + }); + + it("passes messages through verbatim, including special characters", () => { + tryCreate("escaping", [{ name: "email", value: "escaping-probe" }]).then((response) => { + expect(errorMessages(response)).to.contain("lone { brace, ${7*7}, back\\slash and {jcr:title}"); + }); + }); + + it("runs advanced-phase validators only after the default phase passes", () => { + // both phases violated: only the default-phase message surfaces + tryCreate("bothPhases", [ + { name: "email", value: "not-an-email" }, + { name: "score", value: "200" }, + ]).then((response) => { + const messages = errorMessages(response); + expect(messages).to.contain("Please provide a valid email address"); + expect(messages).to.not.contain("advanced phase"); + }); + + // default phase clean: the advanced violation surfaces + tryCreate("advancedOnly", [ + { name: "email", value: "someone@example.org" }, + { name: "score", value: "200" }, + ]).then((response) => { + expect(errorMessages(response)).to.contain("Score must be at most 100 (advanced phase)"); + }); + }); + + it("still validates after a module redeploy invalidates the JS context pool", () => { + // bridges re-resolve JS validators from the live registry on every save; a redeploy of the + // test module must not break validation + cy.runProvisioningScript({ + script: { + fileContent: '- enable: "javascript-modules-engine-test-module"', + type: "application/yaml", + }, + }); + tryCreate("afterRedeploy", [{ name: "email", value: "not-an-email" }]).then((response) => { + expect(errorMessages(response)).to.contain("Please provide a valid email address"); + }); + }); +}); From ece86fb41075f8537219f9e6504de4e217a0183e Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Tue, 21 Jul 2026 20:28:59 +0200 Subject: [PATCH 06/36] feat: typed registerRenderFilter helper + hydrogen sample for JS extension points - registerRenderFilter(): typed wrapper over the existing 'render-filter' registry shape (backward compatible), with applyOn* options accepting arrays and fractional priorities. - Hydrogen gains a self-contained ContactForm component demonstrating all three new extension points: a CSRF-whitelisted POST action receiving the form, a choicelist initializer for the form style, and a node validator on the notification email. --- .../src/framework/registerRenderFilter.ts | 87 +++++++++++++++++++ javascript-modules-library/src/index.ts | 5 ++ ....jahia.modules.jahiacsrfguard-hydrogen.cfg | 3 + .../components/ContactForm/default.server.tsx | 26 ++++++ .../src/components/ContactForm/definition.cnd | 4 + .../ContactForm/extensions.server.tsx | 41 +++++++++ 6 files changed, 166 insertions(+) create mode 100644 javascript-modules-library/src/framework/registerRenderFilter.ts create mode 100644 samples/hydrogen/settings/configurations/org.jahia.modules.jahiacsrfguard-hydrogen.cfg create mode 100644 samples/hydrogen/src/components/ContactForm/default.server.tsx create mode 100644 samples/hydrogen/src/components/ContactForm/definition.cnd create mode 100644 samples/hydrogen/src/components/ContactForm/extensions.server.tsx diff --git a/javascript-modules-library/src/framework/registerRenderFilter.ts b/javascript-modules-library/src/framework/registerRenderFilter.ts new file mode 100644 index 00000000..b689fea6 --- /dev/null +++ b/javascript-modules-library/src/framework/registerRenderFilter.ts @@ -0,0 +1,87 @@ +import type { RenderContext, Resource } from "org.jahia.services.render"; + +/** Declaration of a render filter. */ +export interface RenderFilterDeclaration { + /** Unique key of the filter in the registry. */ + key: string; + /** + * Position of the filter in the render chain (may be fractional). Lower priorities execute + * first. @default 0 + */ + priority?: number; + /** Human-readable description of the filter. */ + description?: string; + /** Only apply the filter to resources of these node types. */ + applyOnNodeTypes?: string | string[]; + /** Only apply the filter in these render modes (e.g. "live", "preview", "edit"). */ + applyOnModes?: string | string[]; + /** Only apply the filter on these render configurations (e.g. "page", "module"). */ + applyOnConfigurations?: string | string[]; + /** Only apply the filter on these templates. */ + applyOnTemplates?: string | string[]; + /** Only apply the filter on these template types (e.g. "html"). */ + applyOnTemplateTypes?: string | string[]; +} + +/** Callbacks of a render filter; both receive the raw Java rendering objects. */ +export interface RenderFilterCallbacks { + /** + * Invoked before the resource is rendered; returning a non-null string short-circuits the chain + * with that output. + */ + prepare?: (renderContext: RenderContext, resource: Resource, chain: unknown) => string | null | undefined; + /** + * Invoked after the resource is rendered, with the output produced so far; returns the + * (possibly transformed) output. Returning null/undefined keeps the previous output. + */ + execute?: ( + previousOutput: string, + renderContext: RenderContext, + resource: Resource, + chain: unknown, + ) => string | null | undefined; +} + +/** + * Registers a render filter, participating in Jahia's render chain like a Java `AbstractFilter`. + * + * ```ts + * registerRenderFilter( + * { key: "myModuleUppercaseTitles", priority: 50, applyOnNodeTypes: "mymodule:title" }, + * { execute: (previousOutput) => previousOutput.toUpperCase() }, + * ); + * ``` + * + * Filters run synchronously on every matching render — keep them fast. + * + * @param declaration The filter declaration; `applyOn*` options restrict when the filter runs. + * @param callbacks The `prepare` and/or `execute` callbacks. + */ +export const registerRenderFilter = ( + { + key, + priority, + description, + applyOnNodeTypes, + applyOnModes, + applyOnConfigurations, + applyOnTemplates, + applyOnTemplateTypes, + }: RenderFilterDeclaration, + { prepare, execute }: RenderFilterCallbacks, +): void => { + server.registry.add("render-filter", key, { + ...(priority !== undefined && { priority }), + ...(description !== undefined && { description }), + ...(applyOnNodeTypes !== undefined && { applyOnNodeTypes: join(applyOnNodeTypes) }), + ...(applyOnModes !== undefined && { applyOnModes: join(applyOnModes) }), + ...(applyOnConfigurations !== undefined && { applyOnConfigurations: join(applyOnConfigurations) }), + ...(applyOnTemplates !== undefined && { applyOnTemplates: join(applyOnTemplates) }), + ...(applyOnTemplateTypes !== undefined && { applyOnTemplateTypes: join(applyOnTemplateTypes) }), + ...(prepare !== undefined && { prepare }), + ...(execute !== undefined && { execute }), + }); + console.debug(`Registered render filter: ${key}`); +}; + +const join = (value: string | string[]): string => (Array.isArray(value) ? value.join(",") : value); diff --git a/javascript-modules-library/src/index.ts b/javascript-modules-library/src/index.ts index 9b7d1a67..9463ba1f 100644 --- a/javascript-modules-library/src/index.ts +++ b/javascript-modules-library/src/index.ts @@ -29,6 +29,11 @@ export { type NodeValidatorContext, type NodeValidatorViolation, } from "./framework/registerNodeValidator.js"; +export { + registerRenderFilter, + type RenderFilterDeclaration, + type RenderFilterCallbacks, +} from "./framework/registerRenderFilter.js"; // Hooks export { useGQLQuery } from "./hooks/useGQLQuery.js"; diff --git a/samples/hydrogen/settings/configurations/org.jahia.modules.jahiacsrfguard-hydrogen.cfg b/samples/hydrogen/settings/configurations/org.jahia.modules.jahiacsrfguard-hydrogen.cfg new file mode 100644 index 00000000..a04e3235 --- /dev/null +++ b/samples/hydrogen/settings/configurations/org.jahia.modules.jahiacsrfguard-hydrogen.cfg @@ -0,0 +1,3 @@ +# Whitelists the contact form action in Jahia's CSRF guard so browsers can POST to it +# without a CSRF token (see docs/2-guides/4-actions). +whitelist = *.hydrogenContact.do diff --git a/samples/hydrogen/src/components/ContactForm/default.server.tsx b/samples/hydrogen/src/components/ContactForm/default.server.tsx new file mode 100644 index 00000000..71ebc276 --- /dev/null +++ b/samples/hydrogen/src/components/ContactForm/default.server.tsx @@ -0,0 +1,26 @@ +import { buildNodeUrl, jahiaComponent } from "@jahia/javascript-modules-library"; + +jahiaComponent( + { + nodeType: "hydrogen:contactForm", + componentType: "view", + displayName: "Contact form", + }, + ({ title, style }: { title: string; style: string }, { currentNode }) => ( +

+

{title}

+ {/* posts to the hydrogenContact action declared in extensions.server.tsx */} +
+ +