From bc4ff83bb250f01c4f5bb11502af17fb3c0b3b6c Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Wed, 1 Jul 2026 10:57:06 +0200 Subject: [PATCH 01/13] WW-5640 docs: design for WebJars support in Struts core Adds first-class WebJars support so client-side libraries can be referenced by a version-less logical path and served through the existing static-content pipeline. Grounded against 7.2.x source. Co-Authored-By: Claude Opus 4.8 --- .../2026-07-01-webjars-support-design.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-webjars-support-design.md diff --git a/docs/superpowers/specs/2026-07-01-webjars-support-design.md b/docs/superpowers/specs/2026-07-01-webjars-support-design.md new file mode 100644 index 0000000000..233e7db5a0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-webjars-support-design.md @@ -0,0 +1,146 @@ +# Spec: WebJars support in Struts core + +**Date:** 2026-07-01 +**JIRA:** [WW-5640](https://issues.apache.org/jira/browse/WW-5640) +**Status:** Design approved — grounded against 7.2.x source; ready for implementation plan +**Scope:** Framework (`core`) only. The consuming plugin work (struts2-bootstrap) is separate, in a separate repo, and starts only after this ships in a Struts release. + +> ASF process note: commit messages must be prefixed with the ticket (`WW-5640 ...`). Before opening a PR, confirm the change is not a security patch (it is a feature) per `SECURITY.md`. + +## Goal + +Add first-class **WebJars** support to Struts core so client-side libraries packaged as WebJars (`org.webjars:*`, shipped under `META-INF/resources/webjars///…`) can be referenced from templates and tags by a **version-less logical path** and served through Struts' existing static-content pipeline. + +Example: a template references `bootstrap/css/bootstrap.min.css`; Struts resolves and serves `META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css`, and the emitted URL is `/static/webjars/bootstrap/5.3.8/css/bootstrap.min.css`. + +## Motivation + +Plugins and applications currently vendor client-side assets directly on the classpath and re-download/re-commit them on every upgrade. The struts2-bootstrap plugin, for example, commits ~2000 Bootstrap + bootstrap-icons files and re-vendors them by hand each release. WebJars replace this with a dependency bump (auto-updatable via Renovate/Dependabot). Struts should let plugins reference WebJar assets by a stable, version-less path and handle resolution + serving centrally. + +**First consumer:** the struts2-bootstrap plugin (separate repo, separate work). + +## Fixed decisions + +| # | Decision | Choice | Rationale | +|---|----------|--------|-----------| +| Q0 | Serving model | **Struts serves** webjar assets through its static-content pipeline (`DefaultStaticContentLoader`), reusing content-type / caching handling. | Framework owns caching + security; works even where the servlet container does not auto-serve `META-INF/resources/`. Consistent with how Struts serves its bundled assets. | +| 1 | Version resolution | **`org.webjars:webjars-locator-lite`** maps version-less path → versioned classpath resource. | Purpose-built for frameworks; MIT; single Apache-2.0 transitive dep; no Jackson/scanner; adopted by Spring Framework 6.2 for the same reasons. | +| 2 | Public contract | **`WebJarUrlProvider` interface** (container-resolvable) **+ thin `` tag and `<@s.webjar>` macro** on top. | Clean injectable Java seam for plugins + template ergonomics. | +| 3 | URL prefix | Serve under the **existing static content path**: `/webjars/…`. | Reuses the already-wired static dispatcher; no new servlet mapping; single enable/disable switch. | +| 4 | Security / allowlist | Hard-constrain resolution to the **`META-INF/resources/webjars/` root** with path normalization (block `..`). **Optional allowlist** of webjar names via a Struts config property; default = all webjars on classpath. | Struts streams classpath bytes, so traversal protection is mandatory; allowlist is opt-in defense-in-depth. | +| 5 | Cache-busting | No query-param cache-buster; the resolved **version lives in the URL path**. | Versioned URLs are inherently cache-stable; no ETag needed beyond current behavior. | +| 6 | Serving hook | **Add a `/webjars/` branch inside `DefaultStaticContentLoader`**, delegating resolution to `WebJarUrlProvider`, then reusing existing `process()`/caching. | Smallest change; single injected loader bean; no new multi-loader dispatch wiring. | +| 7 | MIME coverage | **Extend `getContentType()`** with common webjar asset types (fonts, svg, source maps, json, ico). | Current map returns `null` for `.woff2/.ttf/.svg/.map/.eot/.ico/.json`, breaking web-font loading. Benefits existing static content too. | +| 8 | Tag output | `` / `<@s.webjar>` **emit the resolved URL string** (with optional `var` to store in the value stack). | Composes with the existing ``/`` tags; caller controls the element. | +| 9 | Allowlist config | Struts config property **`struts.webjars.allowlist`** (comma-separated; empty = all). | Consistent with `struts.webjars.enabled` and the rest of the `struts.*` config surface. | +| 10 | Target release | Land in the **next Struts 7.x minor**. | — | + +## Architecture + +One resolution seam, consumed by two callers (serving + URL-building): + +``` + ┌─────────────────────────────┐ + ─────▶│ │ + <@s.webjar> ────▶│ WebJarUrlProvider │───▶ WebJarVersionLocator + │ (DefaultWebJarUrlProvider) │ (webjars-locator-lite, + DefaultStatic ──▶│ │ singleton, cached) + ContentLoader └─────────────────────────────┘ + (/webjars/ branch) +``` + +### Grounding (verified against 7.2.x source) + +- **Dispatch chain:** `StrutsPrepareAndExecuteFilter.tryHandleRequest` → `ExecuteOperations.executeStaticResourceRequest` → `StaticContentLoader.canHandle(path)` → `findStaticResource(path, req, res)`. +- **Loader bean:** single container bean in `core/src/main/resources/struts-beans.xml`: + ``. +- **`canHandle`:** `serveStatic && resourcePath.startsWith(uiStaticContentPath + "/")` (`DefaultStaticContentLoader`). +- **Fixed serving roots today:** `getAdditionalPackages()` → `org.apache.struts2.static`, `template`, `static` (+ debugging in devMode). Webjars need a distinct branch because resolution injects a **version**, so it is not just another package prefix. +- **Caching:** `process()` sets caching headers gated on `serveStaticBrowserCache` (`struts.serve.static.browserCache`); uses a fixed `Last-Modified`, no ETag. Versioned URLs make this sufficient. +- **Config pattern:** `StrutsConstants` (`public static final String struts.*`) + `core/src/main/resources/org/apache/struts2/default.properties` defaults + `@Inject(StrutsConstants.XXX)` setters. +- **DI pattern:** interface + default impl registered in `struts-beans.xml` as `name="struts"`; obtained via `Container.getInstance(Type.class)` or `@Inject`. +- **Tag pattern:** `Component` (or `ContextBean`) subclass + JSP `*Tag` (`getBean`/`populateParams`) + FreeMarker `*Model extends TagModel` (`getBean`) + registration in `StrutsModels` (field + getter). `` builds context-aware URLs via `DefaultUrlHelper.buildUrl` / `request.getContextPath()`. ``/`` already render full elements pointing at static content. +- **Locator API:** `org.webjars.WebJarVersionLocator` (webjars-locator-lite 1.1.3, MIT; one transitive dep `org.jspecify:jspecify:1.0.0`, Apache-2.0). `WEBJARS_PATH_PREFIX = "META-INF/resources/webjars"`. `fullPath(name, filePath)` → versioned classpath path or **`null`** when unresolved. Thread-safe; recommended as a singleton. + +> Class/method names above are verified against the current branch but must be re-confirmed at implementation time (the tree moves). + +## Components + +### `WebJarUrlProvider` (public contract — R3) +Public, stable interface in an appropriate `org.apache.struts2` package (e.g. `org.apache.struts2.views.webjars` — final package TBD during implementation, following neighbours). Container-registered so plugins depend on the interface, not internals. + +Responsibilities: +- **Resolve to classpath resource** (for serving): logical `/` → `META-INF/resources/webjars///`, or empty/absent when unresolved or blocked. +- **Resolve to servable URL** (for tags): ` + + "/webjars/" + //`. +- Apply the **allowlist** and the **enabled** switch. +- Enforce **path normalization** and reject anything escaping `META-INF/resources/webjars/`. + +`DefaultWebJarUrlProvider`: +- Holds a singleton `WebJarVersionLocator` (constructed once; thread-safe). +- `@Inject`s `struts.webjars.enabled`, `struts.webjars.allowlist`, `struts.ui.staticContentPath`. +- Fail-closed: locator `null` → no resource / no URL emitted. + +### Serving branch in `DefaultStaticContentLoader` (R2, Decision 6) +- `canHandle` already matches `/...`; add recognition of the `/webjars/` sub-prefix and gate it additionally on `struts.webjars.enabled`. +- In `findStaticResource`, when the cleaned path starts with `/webjars/`, strip the prefix, hand the logical `/` to `WebJarUrlProvider` for classpath resolution, obtain the resource URL, and stream it through the existing `process()` (reusing caching + the extended content-type map). Unresolved → **404** (fail closed); never fall through to arbitrary classpath serving. + +### Content-type extension (Decision 7) +Extend `getContentType()` to cover at least: `.woff`, `.woff2`, `.ttf`, `.eot`, `.otf`, `.svg`, `.map` (→ `application/json`), `.json`, `.ico`, `.mjs` (→ `text/javascript`). Applies to all static content, not just webjars. + +### `` tag + `<@s.webjar>` macro (R3, Decision 8) +- `WebJar` component (extends `ContextBean` to inherit `var` support) with a `path` attribute; `start`/`end` resolves via `WebJarUrlProvider` and emits the URL string, or stores it in `var` when set. +- JSP `WebJarTag` (`getBean` + `populateParams`); FreeMarker `WebJarModel extends TagModel`; register field + getter in `StrutsModels`. TLD entry is generated from `@StrutsTag`/`@StrutsTagAttribute` annotations. +- Unresolved path → emit nothing (fail closed). + +### Configuration (R4, Decision 9) +New `StrutsConstants` + `default.properties`: +- `struts.webjars.enabled=true` — master switch. +- `struts.webjars.allowlist=` — optional comma-separated webjar names; empty = all. + +Reuse existing `struts.serve.static`, `struts.serve.static.browserCache`, `struts.ui.staticContentPath`. No new prefix constant. + +### Dependency (R6) +Add `org.webjars:webjars-locator-lite` (1.1.x, currently 1.1.3) to `core`. Manage the version in `bom`/`parent` per project convention. Transitive footprint: `org.jspecify:jspecify` only (Apache-2.0). Both Category A — no ASF licensing issue. + +## Security (R5) + +- Normalize the requested path and **reject any resolved path escaping `META-INF/resources/webjars/`** (block `..` traversal and encoded variants; the loader already `URLDecoder.decode`s in `buildPath`, so normalize post-decode). +- Only serve resources whose resolved path comes from the locator (a known webjar); never raw classpath lookups. +- Honour the optional allowlist. +- Honour `struts.webjars.enabled=false` (both serving and URL emission become inert). + +## Data flow + +**Serving:** request `/static/webjars/bootstrap/5.3.8/css/bootstrap.min.css` → filter → `canHandle` (static + `/webjars/` + enabled) → `findStaticResource` strips `/static`, sees `/webjars/…`, asks `WebJarUrlProvider` to resolve to `META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css`, validates containment, `findResource`, `process()` → 200 + content-type + caching headers. Unresolved/blocked → 404. + +**URL building:** `` → component → `WebJarUrlProvider` resolves version and composes `/static/webjars/bootstrap/5.3.8/css/bootstrap.min.css` → emitted string (or stored in `var`). Typically wrapped by the caller: `` or ``. + +## Error handling + +- **Unresolved webjar/path:** serving → 404; URL building → empty output / absent `var`. Never throw to the user; log at debug. +- **Disabled:** `canHandle` returns false for `/webjars/`; tag emits nothing. +- **Traversal / out-of-root:** treated as unresolved → 404 / empty. +- **Locator construction failure:** provider degrades to fail-closed; logged. + +## Testing + +- **Unit — resolution (`DefaultWebJarUrlProvider`):** known path → expected versioned URL; unknown webjar/path → empty; traversal (`bootstrap/../../../etc/passwd`, encoded `..`) → rejected; allowlist blocks a non-listed webjar; `enabled=false` → inert. +- **Unit — content-type:** each new extension maps to the expected MIME type. +- **Integration — serving:** request a served webjar asset → 200 + correct content-type + caching headers governed by `struts.serve.static.browserCache`; `struts.webjars.enabled=false` → 404/not served; unknown asset → 404. +- **Tag/macro:** `` and `<@s.webjar>` render the resolved URL; `var` stores it; unresolved → empty. + +Use JUnit 5 + AssertJ + Mockito, per project convention. A test webjar (e.g. a small `org.webjars` artifact) is added as a `test` dependency to `core` for integration coverage. + +## Out of scope + +- Container-served model (relying on Servlet `META-INF/resources` auto-serving) — rejected in favour of Q0. +- ETag support / per-resource `Last-Modified` — versioned URLs make it unnecessary. +- The consuming plugin's changes (separate work, separate repo). +- Any ``/`` convenience overload that auto-emits an element from a webjar path — possible follow-up; this spec emits URL strings only. + +## Confirm during implementation + +- Final package for `WebJarUrlProvider` and the tag classes (follow existing neighbours). +- Exact `canHandle`/`findStaticResource` edit points against the then-current `DefaultStaticContentLoader`. +- `webjars-locator-lite` version pinned via `bom`/`parent`; re-check latest 1.1.x at implementation time. +- Whether `struts.webjars.allowlist` matches on webjar name only (assumed) vs. name+path prefix. From 52994ef7fe11c8227ac80dc05b5b9747fa3f2fef Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Wed, 1 Jul 2026 11:05:56 +0200 Subject: [PATCH 02/13] WW-5640 docs: implementation plan for WebJars support Co-Authored-By: Claude Opus 4.8 --- .../2026-07-01-WW-5640-webjars-support.md | 1395 +++++++++++++++++ 1 file changed, 1395 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-WW-5640-webjars-support.md diff --git a/docs/superpowers/plans/2026-07-01-WW-5640-webjars-support.md b/docs/superpowers/plans/2026-07-01-WW-5640-webjars-support.md new file mode 100644 index 0000000000..29266000da --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-WW-5640-webjars-support.md @@ -0,0 +1,1395 @@ +# WebJars Support in Struts Core — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let Struts core resolve and serve WebJar assets by a version-less logical path (`bootstrap/css/bootstrap.min.css` → `META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css`) through the existing static-content pipeline, exposing a `WebJarUrlProvider` seam plus a `` tag / `<@s.webjar>` macro. + +**Architecture:** One resolution seam — `WebJarUrlProvider` (default impl wraps a singleton `org.webjars.WebJarVersionLocator` from `webjars-locator-lite`) — consumed by two callers: a new `/webjars/` branch in `DefaultStaticContentLoader` (serving) and a thin `WebJar` component (URL string for tags/macros). Resolution is hard-constrained to the `META-INF/resources/webjars/` root, honours an optional allowlist and a master enable switch, and fails closed (404 / empty output). + +**Tech Stack:** Java 17, Maven (multi-module), `org.webjars:webjars-locator-lite:1.1.3`, JUnit 5 + AssertJ + Mockito, FreeMarker UI templates, Struts container DI (`@Inject`), `struts-annotations` (TLD generation). + +**Spec:** `docs/superpowers/specs/2026-07-01-webjars-support-design.md` — JIRA [WW-5640](https://issues.apache.org/jira/browse/WW-5640). + +## Global Constraints + +- **Commit prefix:** every commit message starts with `WW-5640 ` and ends with the trailer `Co-Authored-By: Claude Opus 4.8 `. +- **Module:** all changes are in the `core` module unless a path says otherwise. +- **Java level:** `maven.compiler.release=17`; no APIs beyond Java 17. +- **Build/test command:** `mvn test -DskipAssembly -pl core` (add `-Dtest=...#...` for a single test). Full-module build for dependency changes: `mvn -q -pl core -am install -DskipAssembly -DskipTests`. +- **Dependency footprint:** `webjars-locator-lite` must add exactly one transitive jar (`org.jspecify:jspecify`, Apache-2.0). Verify — do not let it pull Jackson or a classpath scanner. +- **Fail closed:** unresolved / disabled / allowlist-blocked / traversal → 404 when serving, empty output when building URLs. Never fall through to arbitrary classpath serving. +- **Package for new provider classes:** `org.apache.struts2.webjars`. Component/tag/model packages are fixed by convention (`components`, `views.jsp`, `views.freemarker.tags`). +- **Config defaults:** `struts.webjars.enabled=true`, `struts.webjars.allowlist=` (empty = all). Reuse existing `struts.ui.staticContentPath`, `struts.serve.static`, `struts.serve.static.browserCache`. + +## File Structure + +**New files:** +- `core/src/main/java/org/apache/struts2/webjars/WebJarUrlProvider.java` — public interface (the cooperation seam). +- `core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java` — default impl; wraps `WebJarVersionLocator`; normalization + allowlist + URL composition. +- `core/src/main/java/org/apache/struts2/components/WebJar.java` — UI component emitting the resolved URL (extends `ContextBean`). +- `core/src/main/java/org/apache/struts2/views/jsp/WebJarTag.java` — JSP tag wrapper. +- `core/src/main/java/org/apache/struts2/views/freemarker/tags/WebJarModel.java` — FreeMarker model wrapper. +- `core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java` — provider unit tests. +- `core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java` — serving + content-type tests. +- `core/src/test/java/org/apache/struts2/components/WebJarTest.java` — component/URL-emission test. + +**Modified files:** +- `pom.xml` — version properties for `webjars-locator-lite` and the test webjar. +- `parent/pom.xml` — `` entries for both. +- `core/pom.xml` — declare the two dependencies (main + test). +- `core/src/main/java/org/apache/struts2/StrutsConstants.java` — 3 new constants. +- `core/src/main/resources/org/apache/struts2/default.properties` — 2 new defaults. +- `core/src/main/resources/struts-beans.xml` — register the provider bean. +- `core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java` — bean-selection alias. +- `core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java` — `/webjars/` branch + extended content-type map + `WebJarUrlProvider` injection. +- `core/src/main/java/org/apache/struts2/views/freemarker/tags/StrutsModels.java` — register `webjar` model. + +--- + +### Task 1: Add `webjars-locator-lite` dependency (+ test webjar) + +**Files:** +- Modify: `pom.xml` (properties block, ~line 116–132) +- Modify: `parent/pom.xml` (``, ~line 38+) +- Modify: `core/pom.xml` (``, ~line 145+) + +**Interfaces:** +- Produces: `org.webjars.WebJarVersionLocator` on the `core` compile classpath; a resolvable test webjar (`jquery`) on the `core` test classpath. + +- [ ] **Step 1: Add version properties to root `pom.xml`** + +In `pom.xml`, inside ``, in the "dependency versions in alphanumeric order" list (after the ``/`` neighbours — keep alphabetical), add: + +```xml + 3.7.1 + 1.1.3 +``` + +- [ ] **Step 2: Add managed dependencies to `parent/pom.xml`** + +In `parent/pom.xml`, inside `` (alongside the freemarker/caffeine entries around line 45–55), add: + +```xml + + org.webjars + webjars-locator-lite + ${webjars-locator-lite.version} + + + + org.webjars + jquery + ${webjars-jquery.version} + +``` + +- [ ] **Step 3: Declare the dependencies in `core/pom.xml`** + +In `core/pom.xml`, inside `` (after the freemarker/caffeine block, versionless — versions come from `parent`), add: + +```xml + + org.webjars + webjars-locator-lite + + + + org.webjars + jquery + test + +``` + +- [ ] **Step 4: Build and verify the dependency footprint** + +Run: `mvn -q -pl core -am install -DskipAssembly -DskipTests` +Expected: BUILD SUCCESS. + +Run: `mvn -q -pl core dependency:tree -Dincludes=org.webjars:webjars-locator-lite,org.jspecify:jspecify,com.fasterxml.jackson.core:jackson-databind` +Expected: `webjars-locator-lite` present with `org.jspecify:jspecify` as its only transitive; **no `jackson` line**. If Jackson appears, stop — wrong artifact was added. + +- [ ] **Step 5: Commit** + +```bash +git add pom.xml parent/pom.xml core/pom.xml +git commit -m "WW-5640 build: add webjars-locator-lite dependency + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 2: Add configuration constants and defaults + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java` (after line 318 and after line 435) +- Modify: `core/src/main/resources/org/apache/struts2/default.properties` (after line 107) + +**Interfaces:** +- Produces: `StrutsConstants.STRUTS_WEBJARS_ENABLED` (`"struts.webjars.enabled"`), `STRUTS_WEBJARS_ALLOWLIST` (`"struts.webjars.allowlist"`), `STRUTS_WEBJARS_URL_PROVIDER` (`"struts.webjars.urlProvider"`); default values in `default.properties`. + +- [ ] **Step 1: Add the settings constants** + +In `StrutsConstants.java`, immediately after `STRUTS_UI_STATIC_CONTENT_PATH` (line 318): + +```java + /** + * Whether WebJars support is enabled (serving and URL building) + */ + public static final String STRUTS_WEBJARS_ENABLED = "struts.webjars.enabled"; + + /** + * Optional comma-separated allowlist of WebJar names permitted to be served (empty = all) + */ + public static final String STRUTS_WEBJARS_ALLOWLIST = "struts.webjars.allowlist"; +``` + +- [ ] **Step 2: Add the bean-selection constant** + +In `StrutsConstants.java`, immediately after `STRUTS_STATIC_CONTENT_LOADER` (line 435): + +```java + /** + * The {@link org.apache.struts2.webjars.WebJarUrlProvider} implementation class + */ + public static final String STRUTS_WEBJARS_URL_PROVIDER = "struts.webjars.urlProvider"; +``` + +- [ ] **Step 3: Add defaults to `default.properties`** + +In `default.properties`, after line 107 (`struts.serve.static.browserCache=true`): + +```properties + +### WebJars support +### Master switch for resolving/serving WebJar assets under /webjars/** +struts.webjars.enabled=true +### Optional comma-separated allowlist of WebJar names (empty = all WebJars on the classpath) +struts.webjars.allowlist= +``` + +- [ ] **Step 4: Compile to verify** + +Run: `mvn -q -pl core compile -DskipAssembly` +Expected: BUILD SUCCESS. + +- [ ] **Step 5: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/StrutsConstants.java core/src/main/resources/org/apache/struts2/default.properties +git commit -m "WW-5640 feat: add webjars config constants and defaults + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3: `WebJarUrlProvider` interface + `DefaultWebJarUrlProvider` (TDD) + +**Files:** +- Create: `core/src/main/java/org/apache/struts2/webjars/WebJarUrlProvider.java` +- Create: `core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java` +- Test: `core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java` + +**Interfaces:** +- Consumes: `org.webjars.WebJarVersionLocator` (methods `String path(String name, String filePath)`, `String fullPath(String name, String filePath)`, both return `null` when unresolved; constant `WebJarVersionLocator.WEBJARS_PATH_PREFIX == "META-INF/resources/webjars"`); `StrutsConstants` from Task 2; `StaticContentLoader.Validator.validateStaticContentPath(String)`. +- Produces: + - `WebJarUrlProvider.resolveResourcePath(String logicalPath) -> Optional` (classpath resource under `META-INF/resources/webjars/…`, for serving). + - `WebJarUrlProvider.resolveUrl(String logicalPath, HttpServletRequest request) -> Optional` (servable URL: contextPath + staticContentPath + `/webjars/` + versioned path). + - `WebJarUrlProvider.isEnabled() -> boolean`. + +- [ ] **Step 1: Write the interface** + +Create `WebJarUrlProvider.java`: + +```java +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.struts2.webjars; + +import jakarta.servlet.http.HttpServletRequest; + +import java.util.Optional; + +/** + * Resolves version-less WebJar logical paths (e.g. {@code bootstrap/css/bootstrap.min.css}) to either a + * concrete classpath resource under {@code META-INF/resources/webjars/} (for serving) or a servable URL + * (for tags/macros). Resolution is constrained to the WebJars root, honours an optional allowlist and the + * {@code struts.webjars.enabled} switch, and fails closed (empty result) when unresolved or blocked. + */ +public interface WebJarUrlProvider { + + /** + * @param logicalPath version-less path such as {@code bootstrap/css/bootstrap.min.css} + * @return the concrete classpath resource path (e.g. + * {@code META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css}), or empty + */ + Optional resolveResourcePath(String logicalPath); + + /** + * @param logicalPath version-less path such as {@code bootstrap/css/bootstrap.min.css} + * @param request the current request (used for the servlet context path) + * @return a servable URL, or empty + */ + Optional resolveUrl(String logicalPath, HttpServletRequest request); + + /** + * @return whether WebJars support is enabled + */ + boolean isEnabled(); +} +``` + +- [ ] **Step 2: Write the failing tests** + +Create `DefaultWebJarUrlProviderTest.java` (the `jquery` test webjar from Task 1 provides a real, resolvable WebJar): + +```java +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.struts2.webjars; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DefaultWebJarUrlProviderTest { + + private DefaultWebJarUrlProvider provider; + private HttpServletRequest request; + + @BeforeEach + void setUp() { + provider = new DefaultWebJarUrlProvider(); + provider.setEnabled("true"); + provider.setAllowlist(""); + provider.setStaticContentPath("/static"); + request = mock(HttpServletRequest.class); + when(request.getContextPath()).thenReturn("/myapp"); + } + + @Test + void resolvesKnownResourceToVersionedClasspathPath() { + assertThat(provider.resolveResourcePath("jquery/jquery.min.js")) + .hasValueSatisfying(p -> assertThat(p) + .startsWith("META-INF/resources/webjars/jquery/") + .endsWith("/jquery.min.js")); + } + + @Test + void resolvesKnownResourceToServableUrl() { + assertThat(provider.resolveUrl("jquery/jquery.min.js", request)) + .hasValueSatisfying(u -> assertThat(u) + .startsWith("/myapp/static/webjars/jquery/") + .endsWith("/jquery.min.js")); + } + + @Test + void unknownWebjarResolvesEmpty() { + assertThat(provider.resolveResourcePath("no-such-lib/x.js")).isEmpty(); + assertThat(provider.resolveUrl("no-such-lib/x.js", request)).isEmpty(); + } + + @Test + void traversalIsRejected() { + assertThat(provider.resolveResourcePath("jquery/../../../etc/passwd")).isEmpty(); + assertThat(provider.resolveResourcePath("../jquery/jquery.min.js")).isEmpty(); + } + + @Test + void allowlistBlocksNonListedWebjar() { + provider.setAllowlist("bootstrap"); + assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isEmpty(); + } + + @Test + void allowlistPermitsListedWebjar() { + provider.setAllowlist("jquery, bootstrap"); + assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isPresent(); + } + + @Test + void disabledResolvesEmpty() { + provider.setEnabled("false"); + assertThat(provider.isEnabled()).isFalse(); + assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isEmpty(); + assertThat(provider.resolveUrl("jquery/jquery.min.js", request)).isEmpty(); + } + + @Test + void rootContextPathIsNotDuplicated() { + when(request.getContextPath()).thenReturn("/"); + assertThat(provider.resolveUrl("jquery/jquery.min.js", request)) + .hasValueSatisfying(u -> assertThat(u).startsWith("/static/webjars/jquery/")); + } + + @Test + void blankOrSingleSegmentPathResolvesEmpty() { + assertThat(provider.resolveResourcePath("")).isEmpty(); + assertThat(provider.resolveResourcePath("jquery")).isEmpty(); + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `mvn test -DskipAssembly -pl core -Dtest=DefaultWebJarUrlProviderTest` +Expected: FAIL — `DefaultWebJarUrlProvider` does not exist / does not compile. + +- [ ] **Step 4: Write the implementation** + +Create `DefaultWebJarUrlProvider.java`: + +```java +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.struts2.webjars; + +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.struts2.StrutsConstants; +import org.apache.struts2.dispatcher.StaticContentLoader; +import org.apache.struts2.inject.Inject; +import org.webjars.WebJarVersionLocator; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +/** + * Default {@link WebJarUrlProvider} backed by a singleton {@link WebJarVersionLocator} + * (from {@code webjars-locator-lite}). Thread-safe. + */ +public class DefaultWebJarUrlProvider implements WebJarUrlProvider { + + private static final Logger LOG = LogManager.getLogger(DefaultWebJarUrlProvider.class); + + private static final String WEBJARS_URL_SEGMENT = "/webjars/"; + + private final WebJarVersionLocator locator = new WebJarVersionLocator(); + + private boolean enabled = true; + private Set allowlist = Collections.emptySet(); + private String uiStaticContentPath = StaticContentLoader.DEFAULT_STATIC_CONTENT_PATH; + + @Inject(value = StrutsConstants.STRUTS_WEBJARS_ENABLED, required = false) + public void setEnabled(String enabled) { + this.enabled = BooleanUtils.toBoolean(enabled); + } + + @Inject(value = StrutsConstants.STRUTS_WEBJARS_ALLOWLIST, required = false) + public void setAllowlist(String allowlist) { + Set names = new HashSet<>(); + if (StringUtils.isNotBlank(allowlist)) { + for (String name : allowlist.split(",")) { + String trimmed = name.trim(); + if (!trimmed.isEmpty()) { + names.add(trimmed); + } + } + } + this.allowlist = Collections.unmodifiableSet(names); + } + + @Inject(StrutsConstants.STRUTS_UI_STATIC_CONTENT_PATH) + public void setStaticContentPath(String uiStaticContentPath) { + this.uiStaticContentPath = StaticContentLoader.Validator.validateStaticContentPath(uiStaticContentPath); + } + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public Optional resolveResourcePath(String logicalPath) { + String[] parts = split(logicalPath); + if (parts == null) { + return Optional.empty(); + } + String full = locator.fullPath(parts[0], parts[1]); + if (full == null || !full.startsWith(WebJarVersionLocator.WEBJARS_PATH_PREFIX + "/")) { + return Optional.empty(); + } + return Optional.of(full); + } + + @Override + public Optional resolveUrl(String logicalPath, HttpServletRequest request) { + String[] parts = split(logicalPath); + if (parts == null) { + return Optional.empty(); + } + String versioned = locator.path(parts[0], parts[1]); + if (versioned == null) { + return Optional.empty(); + } + StringBuilder url = new StringBuilder(); + String contextPath = request.getContextPath(); + if (StringUtils.isNotEmpty(contextPath) && !"/".equals(contextPath)) { + url.append(contextPath); + } + url.append(uiStaticContentPath).append(WEBJARS_URL_SEGMENT).append(versioned); + return Optional.of(url.toString()); + } + + /** + * Normalize, validate and split a logical path into {webJarName, filePath}. + * + * @return a two-element array, or {@code null} if disabled, blank, single-segment, + * traversal-tainted, or allowlist-blocked + */ + private String[] split(String logicalPath) { + if (!enabled || StringUtils.isBlank(logicalPath)) { + return null; + } + String normalized = StringUtils.stripStart(logicalPath, "/"); + if (normalized.contains("\\")) { + return null; + } + for (String segment : normalized.split("/")) { + if (segment.equals("..") || segment.equals(".")) { + LOG.debug("Rejecting WebJar path with traversal segment: {}", logicalPath); + return null; + } + } + int slash = normalized.indexOf('/'); + if (slash < 1 || slash == normalized.length() - 1) { + return null; + } + String webJarName = normalized.substring(0, slash); + String filePath = normalized.substring(slash + 1); + if (!isAllowed(webJarName)) { + LOG.debug("WebJar '{}' is not on the allowlist", webJarName); + return null; + } + return new String[]{webJarName, filePath}; + } + + private boolean isAllowed(String webJarName) { + return allowlist.isEmpty() || allowlist.contains(webJarName); + } +} +``` + +Note: `Arrays` import is used by no code above — remove it if your IDE flags it; it is listed only so the import block compiles cleanly if you extend allowlist parsing. (Prefer removing unused imports before commit.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `mvn test -DskipAssembly -pl core -Dtest=DefaultWebJarUrlProviderTest` +Expected: PASS (all 9 tests). + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/webjars/ core/src/test/java/org/apache/struts2/webjars/ +git commit -m "WW-5640 feat: add WebJarUrlProvider resolution seam + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 4: Register the provider bean in the container + +**Files:** +- Modify: `core/src/main/resources/struts-beans.xml` (after line 209) +- Modify: `core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java` (import + after the `StaticContentLoader` alias, ~line 432) + +**Interfaces:** +- Consumes: `WebJarUrlProvider` (Task 3), `StrutsConstants.STRUTS_WEBJARS_URL_PROVIDER` (Task 2). +- Produces: a container-resolvable `WebJarUrlProvider` bean (`name="struts"`), injectable via `@Inject`/`Container.getInstance(WebJarUrlProvider.class)`. + +- [ ] **Step 1: Register the default bean** + +In `struts-beans.xml`, immediately after the `StaticContentLoader` bean (line 208–209): + +```xml + +``` + +- [ ] **Step 2: Add the bean-selection alias** + +In `StrutsBeanSelectionProvider.java`, add the import (with the other `org.apache.struts2.*` imports): + +```java +import org.apache.struts2.webjars.WebJarUrlProvider; +``` + +Then, in `register(...)`, immediately after the `StaticContentLoader` alias line (`alias(StaticContentLoader.class, StrutsConstants.STRUTS_STATIC_CONTENT_LOADER, builder, props);`): + +```java + alias(WebJarUrlProvider.class, StrutsConstants.STRUTS_WEBJARS_URL_PROVIDER, builder, props); +``` + +- [ ] **Step 3: Build to verify wiring** + +Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsBeanSelectionProviderTest` +Expected: PASS (if this test does not exist in the module, run `mvn -q -pl core compile -DskipAssembly` and expect BUILD SUCCESS instead). + +- [ ] **Step 4: Commit** + +```bash +git add core/src/main/resources/struts-beans.xml core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java +git commit -m "WW-5640 feat: register WebJarUrlProvider bean + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 5: Extend static content-type map for WebJar asset types (TDD) + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java` (`getContentType`, lines 330–350) +- Test: `core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java` (create; content-type cases first, serving cases added in Task 6) + +**Interfaces:** +- Produces: `DefaultStaticContentLoader.getContentType(String)` returns correct MIME types for `.woff`, `.woff2`, `.ttf`, `.eot`, `.otf`, `.svg`, `.map`, `.json`, `.ico`, `.mjs` (plus existing types). + +- [ ] **Step 1: Write the failing test** + +Create `DefaultStaticContentLoaderWebJarTest.java`: + +```java +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.struts2.dispatcher; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultStaticContentLoaderWebJarTest { + + private final ContentTypeProbe loader = new ContentTypeProbe(); + + /** Exposes the protected getContentType for assertion. */ + static class ContentTypeProbe extends DefaultStaticContentLoader { + String type(String name) { + return getContentType(name); + } + } + + @Test + void mapsWebJarAssetTypes() { + assertThat(loader.type("x.woff2")).isEqualTo("font/woff2"); + assertThat(loader.type("x.woff")).isEqualTo("font/woff"); + assertThat(loader.type("x.ttf")).isEqualTo("font/ttf"); + assertThat(loader.type("x.otf")).isEqualTo("font/otf"); + assertThat(loader.type("x.eot")).isEqualTo("application/vnd.ms-fontobject"); + assertThat(loader.type("x.svg")).isEqualTo("image/svg+xml"); + assertThat(loader.type("x.map")).isEqualTo("application/json"); + assertThat(loader.type("x.json")).isEqualTo("application/json"); + assertThat(loader.type("x.ico")).isEqualTo("image/x-icon"); + assertThat(loader.type("x.mjs")).isEqualTo("text/javascript"); + } + + @Test + void preservesExistingTypes() { + assertThat(loader.type("x.js")).isEqualTo("text/javascript"); + assertThat(loader.type("x.css")).isEqualTo("text/css"); + assertThat(loader.type("x.png")).isEqualTo("image/png"); + assertThat(loader.type("x.unknown")).isNull(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -DskipAssembly -pl core -Dtest=DefaultStaticContentLoaderWebJarTest#mapsWebJarAssetTypes` +Expected: FAIL — e.g. `.woff2` returns `null`. + +- [ ] **Step 3: Extend `getContentType`** + +In `DefaultStaticContentLoader.getContentType`, replace the existing `if/else` chain body (lines 333–349) with the extended version (keeps all existing entries, adds new ones): + +```java + if (name.endsWith(".js") || name.endsWith(".mjs")) { + return "text/javascript"; + } else if (name.endsWith(".css")) { + return "text/css"; + } else if (name.endsWith(".html")) { + return "text/html"; + } else if (name.endsWith(".txt")) { + return "text/plain"; + } else if (name.endsWith(".gif")) { + return "image/gif"; + } else if (name.endsWith(".jpg") || name.endsWith(".jpeg")) { + return "image/jpeg"; + } else if (name.endsWith(".png")) { + return "image/png"; + } else if (name.endsWith(".svg")) { + return "image/svg+xml"; + } else if (name.endsWith(".ico")) { + return "image/x-icon"; + } else if (name.endsWith(".woff2")) { + return "font/woff2"; + } else if (name.endsWith(".woff")) { + return "font/woff"; + } else if (name.endsWith(".ttf")) { + return "font/ttf"; + } else if (name.endsWith(".otf")) { + return "font/otf"; + } else if (name.endsWith(".eot")) { + return "application/vnd.ms-fontobject"; + } else if (name.endsWith(".json") || name.endsWith(".map")) { + return "application/json"; + } else { + return null; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn test -DskipAssembly -pl core -Dtest=DefaultStaticContentLoaderWebJarTest` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java +git commit -m "WW-5640 feat: extend static content-type map for webjar assets + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 6: Serve `/webjars/**` through `DefaultStaticContentLoader` (TDD) + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java` (imports; new field + `@Inject` setter; refactor 404 block; `findStaticResource` branch; new `findWebJarResource`) +- Test: `core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java` (add serving cases) + +**Interfaces:** +- Consumes: `WebJarUrlProvider.resolveResourcePath(String)` (Task 3); existing `findResource(String)`, `process(InputStream, String, ...)`, `cleanupPath(String)`. +- Produces: serving of `/webjars//` → 200 with the versioned classpath resource; unresolved → 404. + +- [ ] **Step 1: Add the failing serving tests** + +Append to `DefaultStaticContentLoaderWebJarTest.java` (inside the class): + +```java + @org.junit.jupiter.api.Nested + class Serving { + + private DefaultStaticContentLoader newLoader(boolean enabled) { + DefaultStaticContentLoader loader = new DefaultStaticContentLoader(); + loader.setServeStaticContent("true"); + loader.setStaticContentPath("/static"); + loader.setServeStaticBrowserCache("true"); + loader.setEncoding("UTF-8"); + org.apache.struts2.webjars.DefaultWebJarUrlProvider provider = + new org.apache.struts2.webjars.DefaultWebJarUrlProvider(); + provider.setEnabled(String.valueOf(enabled)); + provider.setAllowlist(""); + provider.setStaticContentPath("/static"); + loader.setWebJarUrlProvider(provider); + return loader; + } + + @Test + void servesKnownWebJarAssetWithContentType() throws Exception { + DefaultStaticContentLoader loader = newLoader(true); + jakarta.servlet.http.HttpServletRequest request = + org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletRequest.class); + jakarta.servlet.http.HttpServletResponse response = + org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletResponse.class); + java.io.ByteArrayOutputStream captured = new java.io.ByteArrayOutputStream(); + org.mockito.Mockito.when(response.getOutputStream()) + .thenReturn(new org.apache.struts2.dispatcher.WebJarTestServletOutputStream(captured)); + + loader.findStaticResource("/static/webjars/jquery/jquery.min.js", request, response); + + org.mockito.Mockito.verify(response).setContentType("text/javascript"); + org.mockito.Mockito.verify(response, org.mockito.Mockito.never()) + .sendError(jakarta.servlet.http.HttpServletResponse.SC_NOT_FOUND); + assertThat(captured.size()).isGreaterThan(0); + } + + @Test + void unknownWebJarAssetReturns404() throws Exception { + DefaultStaticContentLoader loader = newLoader(true); + jakarta.servlet.http.HttpServletRequest request = + org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletRequest.class); + jakarta.servlet.http.HttpServletResponse response = + org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletResponse.class); + + loader.findStaticResource("/static/webjars/nope/nope.js", request, response); + + org.mockito.Mockito.verify(response) + .sendError(jakarta.servlet.http.HttpServletResponse.SC_NOT_FOUND); + } + + @Test + void disabledWebJarsReturns404() throws Exception { + DefaultStaticContentLoader loader = newLoader(false); + jakarta.servlet.http.HttpServletRequest request = + org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletRequest.class); + jakarta.servlet.http.HttpServletResponse response = + org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletResponse.class); + + loader.findStaticResource("/static/webjars/jquery/jquery.min.js", request, response); + + org.mockito.Mockito.verify(response) + .sendError(jakarta.servlet.http.HttpServletResponse.SC_NOT_FOUND); + } + } +``` + +Create the minimal `ServletOutputStream` test helper `core/src/test/java/org/apache/struts2/dispatcher/WebJarTestServletOutputStream.java`: + +```java +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.struts2.dispatcher; + +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; + +import java.io.IOException; +import java.io.OutputStream; + +/** Minimal ServletOutputStream backed by an OutputStream, for tests. */ +public class WebJarTestServletOutputStream extends ServletOutputStream { + + private final OutputStream delegate; + + public WebJarTestServletOutputStream(OutputStream delegate) { + this.delegate = delegate; + } + + @Override + public void write(int b) throws IOException { + delegate.write(b); + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(WriteListener writeListener) { + // no-op + } +} +``` + +- [ ] **Step 2: Run the serving tests to verify they fail** + +Run: `mvn test -DskipAssembly -pl core -Dtest=DefaultStaticContentLoaderWebJarTest` +Expected: FAIL — `setWebJarUrlProvider` does not exist / `/webjars/` not handled. + +- [ ] **Step 3: Wire the provider and add the branch** + +In `DefaultStaticContentLoader.java`, add imports (with the existing imports): + +```java +import java.util.Optional; +import org.apache.struts2.webjars.WebJarUrlProvider; +``` + +Add a field (near `encoding`/`devMode`, ~line 108) and its injection setter: + +```java + protected WebJarUrlProvider webJarUrlProvider; + + @Inject + public void setWebJarUrlProvider(WebJarUrlProvider webJarUrlProvider) { + this.webJarUrlProvider = webJarUrlProvider; + } +``` + +Add a class constant (near the top of the class body): + +```java + protected static final String WEBJARS_REQUEST_PREFIX = "/webjars/"; +``` + +Refactor `findStaticResource` (lines 209–243). Replace the whole method with: + +```java + public void findStaticResource(String path, HttpServletRequest request, HttpServletResponse response) + throws IOException { + String name = cleanupPath(path); + + if (name.startsWith(WEBJARS_REQUEST_PREFIX)) { + if (!findWebJarResource(name, path, request, response)) { + sendNotFound(response); + } + return; + } + + for (String pathPrefix : pathPrefixes) { + URL resourceUrl = findResource(buildPath(name, pathPrefix)); + if (resourceUrl != null) { + InputStream is = null; + try { + //check that the resource path is under the pathPrefix path + String pathEnding = buildPath(name, pathPrefix); + if (resourceUrl.getFile().endsWith(pathEnding)) + is = resourceUrl.openStream(); + } catch (IOException ex) { + // just ignore it + continue; + } + + //not inside the try block, as this could throw IOExceptions also + if (is != null) { + process(is, path, request, response); + return; + } + } + } + + sendNotFound(response); + } + + /** + * Resolve and serve a WebJar asset requested under {@code /webjars/**}. + * + * @param name the request path with the static-content prefix stripped, e.g. {@code /webjars/jquery/jquery.min.js} + * @param path the original request path (used for content-type detection) + * @return true if the asset was resolved and streamed; false otherwise (caller sends 404) + */ + protected boolean findWebJarResource(String name, String path, HttpServletRequest request, HttpServletResponse response) + throws IOException { + String logicalPath = name.substring(WEBJARS_REQUEST_PREFIX.length()); + Optional resource = webJarUrlProvider.resolveResourcePath(logicalPath); + if (resource.isEmpty()) { + return false; + } + URL resourceUrl = findResource(resource.get()); + if (resourceUrl == null) { + return false; + } + process(resourceUrl.openStream(), path, request, response); + return true; + } + + protected void sendNotFound(HttpServletResponse response) { + try { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + } catch (IOException e1) { + // we're already sending an error, not much else we can do if more stuff breaks + LOG.warn("Unable to send error response, code: {};", HttpServletResponse.SC_NOT_FOUND, e1); + } catch (IllegalStateException ise) { + // Log illegalstate instead of passing unrecoverable exception to calling thread + LOG.warn("Unable to send error response, code: {}; isCommitted: {};", HttpServletResponse.SC_NOT_FOUND, response.isCommitted(), ise); + } + } +``` + +(The `sendNotFound` body is lifted verbatim from the old inline 404 block so behaviour is unchanged for non-WebJar paths.) + +- [ ] **Step 4: Run all loader tests to verify they pass** + +Run: `mvn test -DskipAssembly -pl core -Dtest=DefaultStaticContentLoaderWebJarTest` +Expected: PASS (content-type + all three serving tests). + +- [ ] **Step 5: Run the full dispatcher test package for regressions** + +Run: `mvn test -DskipAssembly -pl core -Dtest=org.apache.struts2.dispatcher.*` +Expected: PASS (no regression in existing static-content behaviour). + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java core/src/test/java/org/apache/struts2/dispatcher/ +git commit -m "WW-5640 feat: serve webjar assets via static content loader + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 7: `` component + JSP tag + FreeMarker macro (TDD) + +**Files:** +- Create: `core/src/main/java/org/apache/struts2/components/WebJar.java` +- Create: `core/src/main/java/org/apache/struts2/views/jsp/WebJarTag.java` +- Create: `core/src/main/java/org/apache/struts2/views/freemarker/tags/WebJarModel.java` +- Modify: `core/src/main/java/org/apache/struts2/views/freemarker/tags/StrutsModels.java` (field + getter) +- Test: `core/src/test/java/org/apache/struts2/components/WebJarTest.java` + +**Interfaces:** +- Consumes: `WebJarUrlProvider.resolveUrl(String, HttpServletRequest)` (Task 3); `ContextBean` (`var`, `putInContext`, `findString`); `Component` (`@Inject` processed by `container.inject`). +- Produces: `` and `<@s.webjar path="…"/>` emitting the resolved URL string, or storing it in `var`; empty output when unresolved. + +- [ ] **Step 1: Write the failing component test** + +Create `WebJarTest.java`: + +```java +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.struts2.components; + +import jakarta.servlet.http.HttpServletRequest; +import org.apache.struts2.util.ValueStack; +import org.apache.struts2.util.ValueStackFactory; +import org.apache.struts2.config.ConfigurationManager; +import org.apache.struts2.config.providers.XmlConfigurationProvider; +import org.apache.struts2.inject.Container; +import org.apache.struts2.webjars.WebJarUrlProvider; +import org.junit.jupiter.api.Test; + +import java.io.StringWriter; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WebJarTest { + + private WebJar newComponent(ValueStack stack, HttpServletRequest request, WebJarUrlProvider provider) { + WebJar webJar = new WebJar(stack, request); + webJar.setWebJarUrlProvider(provider); + return webJar; + } + + @Test + void writesResolvedUrl() { + ValueStack stack = mock(ValueStack.class); + when(stack.findString("jquery/jquery.min.js")).thenReturn("jquery/jquery.min.js"); + HttpServletRequest request = mock(HttpServletRequest.class); + WebJarUrlProvider provider = mock(WebJarUrlProvider.class); + when(provider.resolveUrl("jquery/jquery.min.js", request)) + .thenReturn(Optional.of("/myapp/static/webjars/jquery/3.7.1/jquery.min.js")); + + WebJar webJar = newComponent(stack, request, provider); + webJar.setPath("jquery/jquery.min.js"); + StringWriter writer = new StringWriter(); + + webJar.start(writer); + webJar.end(writer, ""); + + assertThat(writer.toString()).isEqualTo("/myapp/static/webjars/jquery/3.7.1/jquery.min.js"); + } + + @Test + void unresolvedPathWritesNothing() { + ValueStack stack = mock(ValueStack.class); + when(stack.findString("nope/x.js")).thenReturn("nope/x.js"); + HttpServletRequest request = mock(HttpServletRequest.class); + WebJarUrlProvider provider = mock(WebJarUrlProvider.class); + when(provider.resolveUrl("nope/x.js", request)).thenReturn(Optional.empty()); + + WebJar webJar = newComponent(stack, request, provider); + webJar.setPath("nope/x.js"); + StringWriter writer = new StringWriter(); + + webJar.start(writer); + webJar.end(writer, ""); + + assertThat(writer.toString()).isEmpty(); + } +} +``` + +> If `stack.getContext()` is dereferenced by `ContextBean`/`Component` during `end`, stub it: `when(stack.getContext()).thenReturn(new org.apache.struts2.util.StrutsContext());` — add only if a NullPointerException surfaces at run time. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -DskipAssembly -pl core -Dtest=WebJarTest` +Expected: FAIL — `WebJar` does not exist. + +- [ ] **Step 3: Write the component** + +Create `WebJar.java`: + +```java +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.struts2.components; + +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.struts2.inject.Inject; +import org.apache.struts2.util.ValueStack; +import org.apache.struts2.views.annotations.StrutsTag; +import org.apache.struts2.views.annotations.StrutsTagAttribute; +import org.apache.struts2.webjars.WebJarUrlProvider; + +import java.io.IOException; +import java.io.Writer; +import java.util.Optional; + +/** + *

Resolves a version-less WebJar resource path to a servable URL and writes it to the output + * (or stores it in a variable when {@code var} is set). Compose it with {@code }/{@code } + * or a raw {@code }/{@code