Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dcac5b340 | ||
|
|
6512498c70 | ||
|
|
8096d5ed77 | ||
|
|
ccf2b88094 | ||
|
|
a7373d0ff2 | ||
|
|
e55dad4851 | ||
|
|
e0b898c1a1 | ||
|
|
991fdacd52 | ||
|
|
5fa8d20612 | ||
|
|
1d2c5d2a44 | ||
|
|
88075a7f96 | ||
|
|
11809519a4 | ||
|
|
7b433dec22 | ||
|
|
a3870c4cf7 | ||
|
|
63c4d19965 | ||
|
|
a310ab8284 | ||
|
|
907a754d64 | ||
|
|
179bf8c3d6 | ||
|
|
c78c6523b6 | ||
|
|
36212b7ae3 | ||
|
|
8d363f838e | ||
|
|
4ada6e781c | ||
|
|
e410933f95 | ||
|
|
a77226f2a7 | ||
|
|
403bf550aa | ||
|
|
3543ac97c6 | ||
|
|
61feed02cb | ||
|
|
8cea88e963 | ||
|
|
d15dbcf519 | ||
|
|
e55c177fd1 | ||
|
|
e0b9ef2349 | ||
|
|
57063c51b5 | ||
|
|
33499d537e | ||
|
|
daf392521d | ||
|
|
4008161b93 | ||
|
|
5679967b1b | ||
|
|
05d3ba6f48 | ||
|
|
4c125a2edc | ||
|
|
e49d0268f2 | ||
|
|
c3a13f264f | ||
|
|
96ad92cec9 | ||
|
|
69b6a49e3a | ||
|
|
cc40a179a7 | ||
|
|
403627101f | ||
|
|
33dad0f81a | ||
|
|
1bc548d6c0 | ||
|
|
b9069a1889 | ||
|
|
a30d524ec2 | ||
|
|
ee26b35b33 | ||
|
|
860bd6e63d | ||
|
|
4b572852c9 | ||
|
|
4665cceeb3 | ||
|
|
90e8e34199 | ||
|
|
fe6c6c0b49 | ||
|
|
e07eefc013 | ||
|
|
10f4ecd09d | ||
|
|
20b25ad760 | ||
|
|
185ac88b30 | ||
|
|
d51228af63 | ||
|
|
06da9597af | ||
|
|
611574fb54 | ||
|
|
2591faa0ba | ||
|
|
cec00ab78a | ||
|
|
7e068622c9 | ||
|
|
4d69bcea32 | ||
|
|
9fe815bbfd | ||
|
|
638f7e8c6c | ||
|
|
f0c7fbdac9 | ||
|
|
f24c9e0501 | ||
|
|
5eec2b4446 | ||
|
|
0aad71b127 | ||
|
|
5157b6ef1c | ||
|
|
5dd6d7be69 | ||
|
|
74474fa967 | ||
|
|
266aed750d |
@@ -8,6 +8,8 @@
|
||||
build/
|
||||
*/build/
|
||||
**/build/
|
||||
# ...but re-include the Quarkus runner-jar so docker/quarkus/Dockerfile can layer it on the base image
|
||||
!app/core/build/*-runner.jar
|
||||
out/
|
||||
target/
|
||||
**/target/
|
||||
|
||||
@@ -58,16 +58,18 @@ jobs:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
run: ./gradlew :stirling-pdf:bootJar -PnoSpotless --no-daemon
|
||||
run: ./gradlew :stirling-pdf:quarkusBuild -PnoSpotless --no-daemon
|
||||
|
||||
- name: Locate built JAR
|
||||
id: jar
|
||||
run: |
|
||||
jar=$(find app/core/build/libs -maxdepth 1 -name 'Stirling-PDF*.jar' -o -name 'stirling-pdf*.jar' 2>/dev/null \
|
||||
| grep -vE '(-plain|-sources)\.jar$' | head -n 1)
|
||||
# Quarkus (quarkus.package.jar.type=uber-jar) emits a standalone runnable
|
||||
# jar at app/core/build/<name>-runner.jar, replacing the Spring Boot bootJar
|
||||
# that used to land in app/core/build/libs.
|
||||
jar=$(find app/core/build -maxdepth 1 -name '*-runner.jar' 2>/dev/null | head -n 1)
|
||||
if [[ -z "$jar" ]]; then
|
||||
echo "::error::No JAR under app/core/build/libs"
|
||||
ls -lah app/core/build/libs || true
|
||||
echo "::error::No *-runner.jar under app/core/build"
|
||||
ls -lah app/core/build || true
|
||||
exit 1
|
||||
fi
|
||||
# Absolute path - the migration script pushd's into a temp workdir
|
||||
|
||||
+25
-5
@@ -42,10 +42,15 @@ SwaggerDoc.json
|
||||
# Runtime storage for uploaded files and user data (not Java source code)
|
||||
app/core/storage/
|
||||
|
||||
# Frontend build artifacts copied to backend static resources
|
||||
# These are generated by npm build and should not be committed
|
||||
app/core/src/main/resources/static/assets/
|
||||
# Frontend build artifacts copied to Quarkus static resources
|
||||
# Generated by `npm build` + the copyFrontendAssets/copyFrontendIndexHtml tasks; never committed.
|
||||
# The React bundle goes to META-INF/resources/ (Quarkus serves these over HTTP); index.html is the
|
||||
# only generated file in static/ (ReactRoutingController serves it). See app/core/build.gradle.
|
||||
app/core/src/main/resources/META-INF/resources/
|
||||
app/core/src/main/resources/static/index.html
|
||||
# Migration cleanup: earlier builds emitted the whole bundle into static/. Keep these ignored so
|
||||
# any stale generated assets left in static/ are not accidentally committed.
|
||||
app/core/src/main/resources/static/assets/
|
||||
# Prerendered per-route SPA pages (OG/social-preview), e.g. compress.html. api-landing.html is source.
|
||||
app/core/src/main/resources/static/*.html
|
||||
!app/core/src/main/resources/static/api-landing.html
|
||||
@@ -66,7 +71,7 @@ app/core/src/main/resources/static/pdfjs/
|
||||
app/core/src/main/resources/static/vendor/
|
||||
app/core/src/main/resources/static/**/*.gz
|
||||
app/core/src/main/resources/static/**/*.br
|
||||
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
|
||||
# Note: Keep backend-managed files like fonts/, css/, js/, etc.
|
||||
|
||||
# Gradle
|
||||
.gradle
|
||||
@@ -280,8 +285,23 @@ docs/type3/signatures/
|
||||
|
||||
**/application-dev-local.properties
|
||||
|
||||
# Claude
|
||||
# AI agent session/local files - may contain tokens and secrets
|
||||
.claude/
|
||||
.agents/
|
||||
.cursor/
|
||||
.codex/
|
||||
.opencode/
|
||||
.copilot/
|
||||
.cline/
|
||||
.continue/
|
||||
.windsurf/
|
||||
.junie/
|
||||
.pi/
|
||||
.roo/
|
||||
.augment/
|
||||
.aider*
|
||||
CLAUDE.local.md
|
||||
skills-lock.json
|
||||
|
||||
# Playwright MCP screenshots / traces
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -116,7 +116,7 @@ tasks:
|
||||
codespell:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
|
||||
- uv run --project scripts/pre-commit --no-sync codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment,vertx --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
|
||||
|
||||
toml-sort:
|
||||
deps: [install]
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
# Stirling-PDF: Spring Boot → Quarkus Migration — Continuation Handoff
|
||||
|
||||
> **Purpose:** everything needed to resume this migration in a fresh session. Read this top-to-bottom
|
||||
> before touching anything. Companion doc `migration-report.md` has the higher-level summary; this
|
||||
> file is the working/continuation guide with the concrete state, commands, fixed bugs, remaining
|
||||
> bugs, and the recurring patterns you need to apply.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR status
|
||||
|
||||
- **2026-06-19 — merged `main` (136 commits) into `migration/run-02`.** Resolved 69 conflicts and
|
||||
did **full Spring removal + Quarkus migration of every newly pulled-in file** (36 Spring-bearing
|
||||
files: 21 proprietary + 15 saas). Net effect on main: legacy credits engine deleted (#6687,
|
||||
replaced by PAYG #6589); the proprietary **policy** subsystem and the new **PAYG** subsystem
|
||||
migrated to CDI/JAX-RS/Panache. Verified: 0 conflict markers, 0 `org.springframework` imports in
|
||||
any main source. **`core` + `proprietary` compile; `proprietary` Quarkus-augments + boots + serves
|
||||
real traffic; `saas` now compiles AND augments too** (previously ~28 CDI issues). Cross-file fix:
|
||||
`PolicyExecutor`/`DownstreamEntitlementError` now carry HTTP status+body via
|
||||
`jakarta.ws.rs.WebApplicationException` (was Spring `RestClientResponseException`);
|
||||
`AiWorkflowService.paygLimitResponseOrNull` rewired to it. Repo `save()` shim added to the Panache
|
||||
repos whose callers/tests expect Spring-Data `save()`.
|
||||
- **Branch:** `migration/run-01` (all work committed locally, **nothing pushed** — `origin` is the
|
||||
public `Stirling-Tools/Stirling-PDF` repo; do not push without the owner's say-so).
|
||||
- **Default flavor (`proprietary`):** compiles, Quarkus-augments, boots, and serves real traffic in
|
||||
Docker. ✅
|
||||
- **Cucumber API e2e (full-tool Docker image):** baselines, newest first:
|
||||
- **Run 2 (login off, this session's fixes, no JWT mechanism): 223 / 258 pass**, 35 failed, 80
|
||||
skipped. Up from the prior **183 / 258** baseline (+40). Eliminated buckets: split
|
||||
`PDF corrupted` 8→0, `FileAlreadyExists` 8→0, `Admin login failed (500)` 17→0.
|
||||
- **Run 3 (login off + `V2=true` + the new JWT Bearer mechanism): the 80 JWT/admin scenarios now
|
||||
RUN (0 skipped)** because the `login → /me` probe passes. See §6.E / "Session 2". Final tally
|
||||
recorded in §9.
|
||||
- **Stack:** Quarkus 3.33.2 LTS, **Java 25** (mandatory — see §2), Hibernate ORM Panache,
|
||||
quarkus-rest (RESTEasy Reactive), quarkus-oidc, quarkus-undertow (servlet, for filters), OpenSAML 5.
|
||||
- **`saas` flavor:** compiles but full augmentation has ~28 CDI issues (design-level follow-up).
|
||||
- **JWT Bearer login:** ✅ works end-to-end (token issue + validate → `SecurityIdentity`, role
|
||||
mapping, `@RolesAllowed`). **OAuth2/OIDC + SAML2 SSO:** ✅ both work end-to-end against the
|
||||
`testing/compose` Keycloak stacks; `validate-oauth-test.sh` and `validate-saml-test.sh` both pass
|
||||
(see §6.F). The default e2e Docker image + build helper are committed at `docker/quarkus/` (§3.3).
|
||||
|
||||
---
|
||||
|
||||
## 1. Repo / flavor layout
|
||||
|
||||
Multi-module Gradle build, three selectable flavors via `STIRLING_FLAVOR` (or `ENABLE_SAAS` /
|
||||
`DISABLE_ADDITIONAL_FEATURES`):
|
||||
|
||||
| Flavor | Modules included | Notes |
|
||||
|--------|------------------|-------|
|
||||
| `core` | `:common`, `:stirling-pdf` (core) | OSS only |
|
||||
| `proprietary` (**default**) | + `:proprietary` | what all the e2e work targets |
|
||||
| `saas` | + `:saas` | opt-in: `STIRLING_FLAVOR=saas`; not yet augmentable |
|
||||
|
||||
Module → directory:
|
||||
- `:stirling-pdf` → `app/core` (the runnable Quarkus app; applies the `io.quarkus` gradle plugin)
|
||||
- `:common` → `app/common` (library; CDI beans / JAX-RS / entities)
|
||||
- `:proprietary` → `app/proprietary` (library)
|
||||
- `:saas` → `app/saas` (library, only on saas flavor)
|
||||
|
||||
Quarkus only discovers beans/entities in dependency jars that carry a **Jandex index**; the library
|
||||
modules are indexed via `quarkus.index-dependency.*` in
|
||||
`app/core/src/main/resources/application.properties`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Java 25 is mandatory (don't regress this)
|
||||
|
||||
- The build uses a **JDK 25 toolchain** (`build.gradle` `subprojects { java { toolchain = 25 } }`).
|
||||
- The app is compiled to **class-file version 69 (Java 25)** — it will NOT run on JDK 21.
|
||||
- **The host's default `java` on the PATH is JDK 21.** Use the toolchain JDK 25 explicitly:
|
||||
- `JAVA_HOME` points to a Temurin 25 JDK (`C:\Users\systo\scoop\apps\temurin25-jdk\current`).
|
||||
- In Git Bash run the jar with `"$JAVA_HOME/bin/java" -jar ...` (host `java` = 21 → `UnsupportedClassVersionError`).
|
||||
- The Docker base image `stirlingtools/stirling-pdf-base:1.0.2` ships **Temurin 25.0.2** — so the
|
||||
container runtime is JDK 25 already. Keep it that way; do not switch the base image to a JRE < 25.
|
||||
- Gradle build images / CI also pin `gradle:9.3.1-jdk25` and `eclipse-temurin:25-jre-noble`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Build → package → run → test (the exact loop)
|
||||
|
||||
### 3.1 Build the runnable jar
|
||||
```bash
|
||||
./gradlew :stirling-pdf:quarkusBuild -x test --console=plain
|
||||
```
|
||||
- Produces the **runnable uber-jar** at: `app/core/build/stirling-pdf-2.12.0-runner.jar`
|
||||
- `Main-Class: stirling.software.SPDF.SPDFApplication`.
|
||||
- ⚠️ **GOTCHA:** `app/core/build/libs/stirling-pdf-2.12.0.jar` is the *plain* (non-runnable) jar with
|
||||
an empty manifest. The upstream `docker/embedded/Dockerfile` copies `libs/*.jar` — that's now the
|
||||
WRONG jar. Always use the `-runner.jar`. (`quarkus.package.jar.type=uber-jar` is set in
|
||||
application.properties.)
|
||||
- ⚠️ If the build fails with `Unable to delete .../-runner.jar`, a previous `java -jar` is still
|
||||
holding it. Kill it: PowerShell `Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ?{ $_.CommandLine -like '*stirling-pdf-2.12.0-runner*' } | %{ Stop-Process -Id $_.ProcessId -Force }`.
|
||||
|
||||
### 3.2 Run standalone for a quick boot check (host JDK 25, fastest)
|
||||
```bash
|
||||
SECURITY_ENABLELOGIN=false QUARKUS_HTTP_PORT=8095 \
|
||||
QUARKUS_DATASOURCE_JDBC_URL="jdbc:h2:mem:t;DB_CLOSE_DELAY=-1;MODE=PostgreSQL" \
|
||||
nohup "$JAVA_HOME/bin/java" -jar app/core/build/stirling-pdf-2.12.0-runner.jar > /tmp/boot.log 2>&1 &
|
||||
# success line in log: "Stirling-PDF running on port: 8095" (this app does NOT print Quarkus' "Listening on")
|
||||
```
|
||||
Health: `curl localhost:8095/api/v1/info/status` → `{"version":"2.12.0","status":"UP"}`.
|
||||
|
||||
### 3.3 The "normal" Docker image (full tools) — what the cucumber e2e uses
|
||||
The upstream `docker/embedded/Dockerfile` is **Spring-Boot-specific** (uses
|
||||
`java -Djarmode=tools -jar app.jar extract --layers` + `spring-boot-loader` layers) and does NOT
|
||||
work with the Quarkus jar. For e2e I built an ad-hoc image layering the runner-jar on the prebuilt
|
||||
**base image** (which already has Java 25 + LibreOffice + Tesseract + qpdf + Ghostscript + Calibre +
|
||||
Python). **This Dockerfile lives in a temp dir and needs to be committed into the repo** (see §6 TODO).
|
||||
|
||||
Build context (currently ephemeral at the bash path `/tmp/sp-full` =
|
||||
`C:\Users\systo\AppData\Local\Temp\sp-full`): `app.jar` (the runner jar), `fonts/*.ttf`, and this
|
||||
Dockerfile:
|
||||
```dockerfile
|
||||
FROM stirlingtools/stirling-pdf-base:1.0.2 # Java 25 + all tools
|
||||
WORKDIR /app
|
||||
COPY --chown=1000:1000 app.jar /app/app.jar
|
||||
COPY fonts/*.ttf /usr/share/fonts/truetype/
|
||||
RUN fc-cache -f \
|
||||
&& mkdir -p /storage \
|
||||
&& chown stirlingpdfuser:stirlingpdfgroup /storage /app \
|
||||
&& ln -sf /configs /app/configs && ln -sf /logs /app/logs \
|
||||
&& ln -sf /customFiles /app/customFiles && ln -sf /pipeline /app/pipeline \
|
||||
&& ln -sf /storage /app/storage \
|
||||
&& chown -h stirlingpdfuser:stirlingpdfgroup /app/configs /app/logs /app/customFiles /app/pipeline /app/storage
|
||||
ENV HOME=/home/stirlingpdfuser STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf TEMP=/tmp/stirling-pdf TMP=/tmp/stirling-pdf \
|
||||
SAL_TMP=/tmp/stirling-pdf/libre DBUS_SESSION_BUS_ADDRESS=/dev/null \
|
||||
JAVA_OPTS="-XX:+UseG1GC -Djava.awt.headless=true" \
|
||||
QUARKUS_HTTP_HOST=0.0.0.0 QUARKUS_HTTP_PORT=8080
|
||||
EXPOSE 8080/tcp
|
||||
STOPSIGNAL SIGTERM
|
||||
USER stirlingpdfuser
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||
```
|
||||
Stage + build + run:
|
||||
```bash
|
||||
# stage (bash /tmp resolves to %LOCALAPPDATA%\Temp)
|
||||
mkdir -p /tmp/sp-full/fonts
|
||||
cp app/core/build/stirling-pdf-2.12.0-runner.jar /tmp/sp-full/app.jar
|
||||
cp app/core/src/main/resources/static/fonts/*.ttf /tmp/sp-full/fonts/
|
||||
# (write the Dockerfile above to C:\Users\systo\AppData\Local\Temp\sp-full\Dockerfile)
|
||||
cd /tmp/sp-full && docker build -t stirling-pdf-quarkus:full .
|
||||
docker rm -f sp-e2e
|
||||
docker run -d --name sp-e2e -p 8080:8080 \
|
||||
-e SECURITY_ENABLELOGIN=false -e METRICS_ENABLED=true \
|
||||
-e SYSTEM_DEFAULTLOCALE=en-US -e SYSTEM_MAXFILESIZE=100 \
|
||||
stirling-pdf-quarkus:full
|
||||
# wait for: curl localhost:8080/api/v1/info/status == 200
|
||||
```
|
||||
Base image was pulled with `docker pull stirlingtools/stirling-pdf-base:1.0.2`.
|
||||
|
||||
### 3.4 Run the cucumber (behave) suite
|
||||
- Tests live in `testing/cucumber/` — Python **behave** (BDD), pure HTTP via `requests` (no browser).
|
||||
- **Target URL is hardcoded `http://localhost:8080`** in `features/steps/step_definitions.py`
|
||||
(lines ~584/592/601) and `features/environment.py`. Easiest is to run the app on 8080.
|
||||
- `behave.ini` excludes `features/(enterprise|payg)` and tag `~@manual` by default.
|
||||
- `environment.py` probes `/api/v1/auth/login` (admin/stirling) at startup; if JWT/login is not
|
||||
functional (login disabled / V2) it **skips** all `@jwt @login @me @refresh @token @mfa @apikey
|
||||
@admin_settings @audit @signature @team @user_mgmt` scenarios → ~80 skips. That's expected.
|
||||
|
||||
Install deps + run:
|
||||
```bash
|
||||
cd testing/cucumber
|
||||
pip install -r requirements.txt # behave, requests, pypdf, reportlab, psycopg, pillow, ...
|
||||
TEST_CONTAINER_NAME=sp-e2e TEST_REPORT_DIR=/tmp python -m behave --no-capture --format progress2
|
||||
# single feature: python -m behave features/general.feature
|
||||
# one scenario: python -m behave features/general.feature:22 --format plain
|
||||
```
|
||||
The official CI driver is `testing/test.sh` (builds images via `docker/embedded/Dockerfile.*` and
|
||||
runs behave) — it will need the Dockerfile fixes from §6 before it works on Quarkus.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bugs FIXED this session (with the *why*, so you can spot siblings)
|
||||
|
||||
### Session 2 (branch `claude/happy-chaplygin-906fe7`, fast-forwarded from `migration/run-01`)
|
||||
|
||||
Newest first. These took the login-off suite **183 → 223** and then wired JWT so the **80 skipped
|
||||
JWT/admin scenarios run** (run 3, §9):
|
||||
|
||||
1. **JWT Bearer → `SecurityIdentity` was never populated** → every user-scoped endpoint that reads
|
||||
`SecurityIdentity.getPrincipal()` (folders, files, `/me`, user/team settings…) failed, and the
|
||||
`environment.py` probe (`login → /me`) failed so ~80 scenarios auto-skipped. Added a custom
|
||||
`HttpAuthenticationMechanism` + `IdentityProvider` in
|
||||
`app/proprietary/.../security/identity/` (`JwtBearerAuthenticationMechanism`,
|
||||
`JwtTokenIdentityProvider`): extract `Authorization: Bearer`, validate via the existing
|
||||
`JwtService` (jjwt + keystore), build a `QuarkusSecurityIdentity` and map the `role` claim
|
||||
(`ROLE_ADMIN` → also add `ADMIN` so `@RolesAllowed("ADMIN")` matches). Returns no identity when
|
||||
no Bearer is present, so the X-API-KEY / login-off open-endpoint path is unaffected. **This is the
|
||||
IdentityProvider that ~10 `// TODO: Migration required` comments across the security/storage code
|
||||
asked for.** Run with `V2=true`.
|
||||
2. **No admin user was ever created** → all logins failed "No user found: admin". `InitialSecuritySetup`
|
||||
was a Spring `@Component` (eagerly constructed, `@PostConstruct` ran every boot); the migration
|
||||
made it a lazy `@ApplicationScoped` whose `@PostConstruct` never ran. Restored eager init via
|
||||
`@Observes StartupEvent`. **Pattern: any migrated `@PostConstruct`-on-`@ApplicationScoped` startup
|
||||
bean with no injector is dead code — grep for them.**
|
||||
3. **Eager init then exposed two latent bugs** (both real, both now fixed):
|
||||
- `@Produces @ApplicationScoped DataSource` → Arc generated the client proxy in the JDK-sealed
|
||||
`javax.sql` package → `NoClassDefFoundError` on first use. Fix: `@Singleton` (pseudo-scope, no
|
||||
proxy). **Audit other `@Produces @ApplicationScoped` whose return type is a `java.*`/`javax.*`
|
||||
type.**
|
||||
- Panache `persist()` in the `StartupEvent` observer ran with no transaction (Spring Data wrapped
|
||||
`save()` implicitly). Fix: `@Transactional` on the observer.
|
||||
4. **Login returned 500 instead of 401** for unknown user / bad password. `CustomUserDetailsService`
|
||||
threw `IllegalArgumentException`, but `AuthController` catches the migration shim
|
||||
`stirling.software.common.security.UsernameNotFoundException`. Made the service throw the shim
|
||||
type. **Sibling: the locked-account path still throws `IllegalStateException` — wire it similarly
|
||||
when needed.**
|
||||
5. **`@Transactional` missing on policy-store reads** (`JpaPolicyStore.all()`,
|
||||
`findByTriggerType()`) → the scheduled folder-watch/schedule triggers threw
|
||||
`ContextNotActiveException` off-request (§6.C). The reads are reached via the CDI proxy so a
|
||||
method-level `@Transactional` applies even from the background virtual-thread executor.
|
||||
6. **Split scenarios sent a duplicate `fileInput` text part** (`| fileInput | fileInput |` in
|
||||
`general.feature`) alongside the file part; Quarkus `@RestForm FileUpload` bound the *text* part
|
||||
("fileInput", 9 bytes) → "PDF corrupted". Spring ignored the stray part. Removed the redundant
|
||||
rows (the file is already attached via the generate step). **Real clients send one part, so this
|
||||
is a test artifact, not a server tolerance gap worth chasing.**
|
||||
7. **e2e Docker build is now first-class:** `docker/quarkus/Dockerfile` (+ `README.md`,
|
||||
`build-and-run.sh`) layers the runner-jar on the base image, and `.dockerignore` re-includes
|
||||
`app/core/build/*-runner.jar` (it was excluded by `**/build/`, so a clean `docker build` had been
|
||||
silently relying on BuildKit cache).
|
||||
|
||||
### Session 1
|
||||
|
||||
1. **`MultipartFile.transferTo` didn't overwrite** (`a30d524ec`).
|
||||
`app/common/.../model/MultipartFile.java` + `.../model/multipart/FileUploadMultipartFile.java`
|
||||
used `Files.copy(in, dest)` without `REPLACE_EXISTING`. Callers do
|
||||
`Files.createTempFile(...)` (creates the file) then `transferTo(thatPath)` → `FileAlreadyExistsException`.
|
||||
Spring's `transferTo` overwrites. **Fixed** by adding `StandardCopyOption.REPLACE_EXISTING`.
|
||||
Fixes the whole class of `/api/v1/misc/*` failures (scanner-effect, replace-invert, ocr,
|
||||
update-metadata, unlock-pdf-forms, repair, extract-image-scans, add-page-numbers, …).
|
||||
|
||||
2. **`maxDPI` defaulted to 0** (`a30d524ec`).
|
||||
`ApplicationProperties.System.maxDPI` is a primitive `int` (→ 0 when not bound from settings).
|
||||
Every DPI guard (`dpi > maxDPI`) then failed with *"maximum safe limit of 0"*. The
|
||||
`settings.yml.template` default is 500. **Fixed** by `private int maxDPI = 500;`.
|
||||
⚠️ Root cause hint: this strongly suggests **settings.yml → ApplicationProperties config binding
|
||||
is incomplete in the Quarkus migration**. Other primitive/unset fields may also be silently
|
||||
wrong. Worth a dedicated audit (see §5).
|
||||
|
||||
3. **Request-path `HttpServletRequest` → `UT000048` "No request is currently active"** (`860bd6e63`,
|
||||
`4b572852c`). This was the dominant blocker. `quarkus-rest` (RESTEasy Reactive) runs handlers on
|
||||
reactive/worker threads where the undertow servlet request context is NOT active, so ANY
|
||||
`HttpServletRequest.getX()` throws. Fixed in:
|
||||
- `GlobalExceptionHandler` (an `ExceptionMapper` that threw while handling *every* error, masking
|
||||
the real cause) → `@Context UriInfo` + exception-safe `requestUri()`.
|
||||
- `ControllerAuditAspect`, `AuditAspect` → route through the already-guarded
|
||||
`AuditService.getCurrentRequest()` (returns null off-request) + a guarded `safeResponse()`.
|
||||
- `AutoJobAspect`, `JobExecutorService` → inject `io.quarkus.vertx.http.runtime.CurrentVertxRequest`,
|
||||
read query-param/method/path/attributes from the Vert.x request, degrade to null/no-op.
|
||||
- `AuthController`, `UserController`, `ConfigController` → `@Context UriInfo` / `HttpHeaders` /
|
||||
`io.vertx.core.http.HttpServerRequest`.
|
||||
This unblocked the entire `@AutoJobPostMapping` chain (most PDF endpoints).
|
||||
|
||||
4. **License singleton PK race** (`860bd6e63`). `UserLicenseSettings` has a manually-assigned
|
||||
`@Id = 1L`. Spring Data `save()` on a non-new (pre-set-id) entity does a **MERGE (upsert)**; the
|
||||
migration converted it to `persist()` (INSERT-only). The startup license sync raced the first
|
||||
request, both inserted id=1 → `JdbcSQLIntegrityConstraintViolationException` → app crash. **Fixed**
|
||||
in `UserLicenseSettingsService.getOrCreateSettings()` with a JVM lock +
|
||||
`io.quarkus.narayana.jta.QuarkusTransaction.requiringNew()` create-once, then reload into the
|
||||
caller's tx. **⚠️ This `save()`→`persist()`-should-be-`merge()` bug almost certainly exists for
|
||||
OTHER manually-`@Id`'d entities — audit them (see §5).**
|
||||
|
||||
5. **App couldn't boot without Redis** (`4665cceeb`).
|
||||
- `quarkus.oidc.enabled=false` default (quarkus-oidc aborts startup without `auth-server-url`;
|
||||
re-enable for an OAuth2 deployment).
|
||||
- Valkey backplane beans eagerly injected the inactive `RedisDataSource`. Gated all 7 with
|
||||
**build-time** `@io.quarkus.arc.properties.IfBuildProperty(name="cluster.backplane", stringValue="valkey")`
|
||||
(NOT `@LookupIfProperty` — that leaves the bean in the build, so `RedisDataSource` still has a
|
||||
consumer and Quarkus emits an eager startup observer that fails). Plus
|
||||
`quarkus.redis.health.enabled=false`.
|
||||
|
||||
6. **Runtime boot fixes** (`20b25ad76`): `quarkus.hibernate-orm.mapping.format.global=ignore` (JSON
|
||||
columns), Quartz cron `0 0 0 * * MON` → `0 0 0 ? * MON` (Quartz rejects `*` in both day fields),
|
||||
`@Scheduled(every="7d")` → `"P7D"`, `quarkus.arc.fail-on-intercepted-private-method=false`.
|
||||
|
||||
7. **CDI augmentation** (`185ac88b3`): interceptor bindings made `@InterceptorBinding`
|
||||
(`@EnterpriseEndpoint`, `@PremiumEndpoint`), a `tools.jackson.databind.ObjectMapper` producer
|
||||
added in `AppConfig` (92 injection points), ambiguous beans resolved (`@DefaultBean`),
|
||||
`Optional<X>`→`Instance<X>`, collection `List<X>`→`@All List<X>`, nested `SAML2` config producer.
|
||||
|
||||
8. **Test layer** (`d51228af6`): a content-based exclude in root `build.gradle subprojects` skips any
|
||||
test still importing `org.springframework`/`com.nimbusds` (self-maintaining), plus an explicit
|
||||
list for tests asserting changed production signatures.
|
||||
|
||||
---
|
||||
|
||||
## 5. Recurring patterns / gotchas (apply these everywhere)
|
||||
|
||||
- **HttpServletRequest is poison on reactive threads.** ~35 main-source files still reference
|
||||
`HttpServletRequest` (see §6 list). For each in the request path, replace with:
|
||||
- path/URI → `@Context jakarta.ws.rs.core.UriInfo` (`uriInfo.getRequestUri().getPath()`), or in a
|
||||
non-JAX-RS bean inject `io.quarkus.vertx.http.runtime.CurrentVertxRequest`
|
||||
(`currentVertxRequest.getCurrent().request().path()`), guarded in try/catch returning null/"".
|
||||
- headers → `@Context jakarta.ws.rs.core.HttpHeaders` (`getHeaderString(name)`).
|
||||
- remote addr / method → `@Context io.vertx.core.http.HttpServerRequest`.
|
||||
- request attributes (`get/setAttribute`) → Vert.x `RoutingContext.get/put` via `CurrentVertxRequest`.
|
||||
- In services that already have a guarded accessor, reuse `AuditService.getCurrentRequest()`.
|
||||
- **Spring `save()` → Panache:** if the entity uses `@GeneratedValue` (new on insert) → `persist()`.
|
||||
If the entity has a **manually-assigned `@Id`** (caller sets the id, "upsert" semantics) →
|
||||
`getEntityManager().merge()` (NOT `persist()`), and consider concurrency.
|
||||
- **Config gating:** runtime selection that must REMOVE a bean (so its deps don't get wired) →
|
||||
build-time `@IfBuildProperty`/`@UnlessBuildProperty`. `@LookupIfProperty` only disables *lookup*,
|
||||
the bean and its injection points stay in the build.
|
||||
- **`quarkus.*` build-time props** (e.g. `quarkus.oidc.enabled`, `quarkus.hibernate-orm.*`,
|
||||
`quarkus.arc.*`) can't be overridden by env at runtime — they require a rebuild.
|
||||
- **settings.yml binding is suspect** (see maxDPI). Audit `ApplicationProperties` for primitive
|
||||
fields that need non-zero/template defaults, and verify the settings.yml → ApplicationProperties
|
||||
binding path actually works in Quarkus (it was Spring `@ConfigurationProperties` + a custom YAML
|
||||
property source — see the `YamlPropertySourceFactory` / `ConfigInitializer` TODOs).
|
||||
- **Augment gate:** `compileJava` passing ≠ working. `./gradlew :stirling-pdf:quarkusBuild` surfaces
|
||||
CDI wiring errors; only *running* surfaces the `UT000048` / config / race bugs. Always run.
|
||||
- **Jackson 2 vs 3 coexist:** ~100 files use `tools.jackson` (Jackson 3, from Spring Boot 4); REST
|
||||
(de)serialization uses Quarkus' Jackson 2. Don't "fix" `tools.jackson` imports — there's a producer.
|
||||
|
||||
---
|
||||
|
||||
## 6. REMAINING WORK (prioritized)
|
||||
|
||||
### A. Make the e2e Docker build first-class
|
||||
- [x] **DONE (Session 2):** `docker/quarkus/Dockerfile` (+ `README.md`, `build-and-run.sh`) committed,
|
||||
uses the runner-jar, copies fonts; `.dockerignore` re-includes `app/core/build/*-runner.jar`.
|
||||
- [ ] Rewrite/replace `docker/embedded/Dockerfile`, `Dockerfile.fat`, `Dockerfile.ultra-lite` for
|
||||
Quarkus: drop the Spring-Boot `-Djarmode=tools extract --layers` + `spring-boot-loader` layer
|
||||
copies; either copy the uber `-runner.jar` to `/app/app.jar` or use the Quarkus fast-jar
|
||||
(`quarkus-app/`) layout. The stage-1 `gradle clean build -PbuildWithFrontend=true` still builds
|
||||
the frontend (fine).
|
||||
- [ ] Update `scripts/init.sh` / `init-without-ocr.sh` — they have Spring-loader fallbacks and AOT
|
||||
machinery; the primary `java -jar /app.jar` path works for the uber-jar, but verify the AOT
|
||||
cache + `restart-helper.jar` paths.
|
||||
- [ ] Then `testing/test.sh` (the official cucumber driver) should work end-to-end.
|
||||
|
||||
### B. Real per-endpoint bugs surfaced by cucumber (login-off suite)
|
||||
Last measured failure buckets (before the transferTo/maxDPI fixes — re-run to refresh):
|
||||
- [ ] **`PdfCorruptedException` (~48)** on `convert/pdf/{word,vector,presentation,text,pdfa,...}`,
|
||||
`convert/{html,cbz}/pdf`. Investigate `CustomPDFDocumentFactory` (PDF loading) — is it
|
||||
misreporting valid PDFs as corrupted, or do these convert paths need LibreOffice/handling that
|
||||
errors first and gets wrapped? Check one: `python -m behave features/convert_new.feature:NN --format plain`
|
||||
then read `docker logs sp-e2e` for the real cause.
|
||||
- [ ] **`ClassCastException: String cannot be cast to ...` (~6)** — form/param binding type mismatch.
|
||||
Likely a `@RestForm`/`@QueryParam` bound to the wrong type, or a Map/JSON form field. Check
|
||||
`form/fill`, `form_advanced.feature`.
|
||||
- [ ] **Remaining `500`s** after A/B fixes — `misc/compress-pdf`, `general/split-pdf-by-chapters`,
|
||||
`misc/add-image`, etc. Triage each via container logs.
|
||||
- [ ] **`400`s (~5)** — multipart `@RestForm` binding gaps. The migration left several request DTOs
|
||||
with `MultipartFile`/POJO-list fields not bound to RESTEasy `FileUpload` (AI/workflow/sign DTOs
|
||||
explicitly flagged). See `migration-report.md` "Representative deferred code".
|
||||
- [ ] **temp-file collisions other than transferTo** — also check `GeneralUtils.createTempFile`
|
||||
(`app/common/.../util/GeneralUtils.java:79/85`) and any `Files.createFile`/`Files.copy`/
|
||||
`Files.move` without `REPLACE_EXISTING`. `temp<rand>genericNonCustomisableName.pdf` and
|
||||
`/tmp/stirling-pdf/stirling-pdf-<rand>.pdf` were two such names.
|
||||
|
||||
### C. Background scheduled-task errors (log noise, not request-breaking)
|
||||
- [ ] `FolderWatchTrigger` (reconcile) and `ScheduleTrigger` (sweep) throw
|
||||
`jakarta.enterprise.context.ContextNotActiveException` ("neither a transaction nor a CDI
|
||||
request context is active") because they hit Panache/`PolicyRepository` off-request. Add
|
||||
`@Transactional` (and/or `@ActivateRequestContext`) to those scheduled methods, or wrap the EM
|
||||
access in `QuarkusTransaction.requiringNew()`. Files:
|
||||
`app/proprietary/.../policy/trigger/FolderWatchTrigger.java`,
|
||||
`.../policy/trigger/ScheduleTrigger.java`, `.../policy/store/JpaPolicyStore.java`.
|
||||
|
||||
### D. The remaining ~35 `HttpServletRequest` files (apply §5 pattern as they surface)
|
||||
Not all are in the hot path; fix the ones that throw `UT000048` when their endpoints are exercised.
|
||||
Get the list any time with:
|
||||
```bash
|
||||
grep -rln "HttpServletRequest" app/core/src/main app/proprietary/src/main app/common/src/main --include=*.java
|
||||
```
|
||||
Known-fixed already: GlobalExceptionHandler, ControllerAuditAspect, AuditAspect, AutoJobAspect,
|
||||
JobExecutorService, AuthController, UserController, ConfigController. Everything else is unverified.
|
||||
High-risk: security filters (`UserAuthenticationFilter`, rate-limit filters, `JwtAuthenticationFilter`),
|
||||
anything reading headers/cookies/remote-addr per request.
|
||||
|
||||
### E. Auth / JWT / login — DONE (Session 2)
|
||||
The whole Quarkus auth-identity layer is now in place (`app/proprietary/.../security/identity/`):
|
||||
- [x] **JWT Bearer** → `JwtBearerAuthenticationMechanism` + `JwtTokenIdentityProvider` (validate via
|
||||
`JwtService`, map `role` claim). Run with `V2=true`.
|
||||
- [x] **X-API-KEY** → `ApiKeyAuthenticationMechanism` + `ApiKeyAuthenticationRequest` +
|
||||
`ApiKeyIdentityProvider` (resolve via `userService.getUserByApiKey`). Lets `X-API-KEY` requests
|
||||
authenticate (e.g. `/me`), and lets the suite run `SECURITY_ENABLELOGIN=true`.
|
||||
- [x] **User-as-principal** → `UserSecurityIdentityAugmentor` re-loads the `User` and sets it as the
|
||||
`SecurityIdentity` principal; `User implements Principal`. This satisfies the ~7
|
||||
`principal instanceof User` sites (folders, file storage, sessions, audit, UserController) — the
|
||||
augmentor every `// TODO: Migration required` in security/storage asked for.
|
||||
- [x] **Config binding** → `ApplicationPropertiesConfigOverlay` overlays env/config onto
|
||||
`ApplicationProperties` at startup (the Spring `@ConfigurationProperties` bind was never
|
||||
migrated, so `SECURITY_ENABLELOGIN` / `SECURITY_CUSTOMGLOBALAPIKEY` / `STORAGE_ENABLED` were
|
||||
ignored — root cause of the maxDPI/loginAttemptCount class too). **Currently a focused subset
|
||||
(auth/storage/SSO toggles); a complete generic bind (all ~445 fields + settings.yml) is still
|
||||
TODO.**
|
||||
- Validated on a login-ON probe (`SECURITY_ENABLELOGIN=true V2=true STORAGE_ENABLED=true
|
||||
SECURITY_CUSTOMGLOBALAPIKEY=123456789`): open PDF endpoints (anon), JWT login+/me, X-API-KEY /me,
|
||||
folder list/create all work. The 183 open endpoints stay open (no global `quarkus.http.auth.*`
|
||||
policy), so login-ON does not regress them.
|
||||
|
||||
### F. SAML / SSO — DONE (Session 2), both flows work end-to-end
|
||||
|
||||
**Both `validate-oauth-test.sh` and `validate-saml-test.sh` pass, and both full login flows were
|
||||
verified end-to-end against the Keycloak compose** (login -> IdP -> callback/ACS -> auto-created
|
||||
user -> app JWT cookie -> `/me` 200). Run with `PREMIUM_KEY=<your enterprise license key>`. Tag the Quarkus image as
|
||||
`docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest` so the compose uses it (or repoint the
|
||||
`image:`); `start-saml-test.sh` generates the SP certs + fetches Keycloak's cert.
|
||||
|
||||
- **OAuth2 / OIDC** (`security/oauth2/`): `OAuth2LoginController` (JAX-RS) serves
|
||||
`/oauth2/authorization/{id}` -> IdP authorize redirect; `OAuth2CallbackServlet` (`@WebServlet
|
||||
/login/oauth2/code/*`) does the code exchange + userinfo + auto-create + JWT cookie. **Why a
|
||||
servlet for the callback:** quarkus-undertow's default servlet owns the `/login/*` prefix and
|
||||
query-strips/intercepts the extension-less callback before RESTEasy sees it; a registered
|
||||
`@WebServlet` takes precedence. (Same reason the SAML SP endpoints are servlets.)
|
||||
- **SAML2** (`security/saml2/`): `Saml2Service` (OpenSAML 5) initialises the library, loads SP
|
||||
key/cert + IdP cert, builds SP metadata, builds+signs the redirect-binding AuthnRequest, and
|
||||
validates the SAMLResponse signature (`SAMLSignatureProfileValidator` + `SignatureValidator`
|
||||
against the IdP cert). `SamlMetadataServlet` -> `/saml2/service-provider-metadata/{id}`;
|
||||
`SamlSpServlet` -> `/saml2/authenticate/{id}` (login init) + `/login/saml2/sso/{id}` (ACS).
|
||||
**Gotcha:** the SP entityId must equal the SP-metadata URL (`{backendUrl}/saml2/service-provider-
|
||||
metadata/{id}`), which is what Keycloak's SAML client is keyed on - NOT the bare
|
||||
`SECURITY_SAML2_SP_ENTITYID` host (`SamlConfig` derives it). Keycloak's realm has
|
||||
`saml.client.signature=false` (AuthnRequest signature optional) + `saml.server.signature=true`
|
||||
(so the ACS validates the response against Keycloak's cert).
|
||||
- Both flows finish by issuing the app JWT as the `stirling_jwt` cookie, which
|
||||
`JwtBearerAuthenticationMechanism` now also reads (not just the `Authorization` header) -> feeds
|
||||
the `UserSecurityIdentityAugmentor` (principal = User).
|
||||
- Follow-ups: logout/SLO endpoints; encrypted-assertion handling; the `mcp` Keycloak compose;
|
||||
desktop/Tauri RelayState (`TauriSamlUtils` preserved). Multi-provider (google/github) OAuth uses
|
||||
the same pattern keyed by registrationId.
|
||||
|
||||
### F-old. (superseded) original SAML/SSO scoping
|
||||
The `validate-*-test.sh` scripts are **endpoint-existence
|
||||
checks** (Keycloak up + Stirling serves the SSO endpoint), not full browser logins.
|
||||
|
||||
Prereqs for any run: the compose files use `image: docker.stirlingpdf.com/.../stirling-pdf:latest`
|
||||
(the published Spring image) — **repoint to `stirling-pdf-quarkus:jwt`** (or wire
|
||||
`docker/quarkus/Dockerfile`). The SAML compose mounts `saml-private-key.key`/`saml-public-cert.crt`/
|
||||
`keycloak-saml-cert.pem` which **do not exist in the repo** — generate them (the SP signing
|
||||
key/cert; `start-saml-test.sh` may do this). SAML/OAuth need the **Enterprise license** env.
|
||||
|
||||
**OAuth2 / OIDC** (more tractable — Quarkus has `quarkus-oidc`):
|
||||
- [ ] Extend `ApplicationPropertiesConfigOverlay` for `security.oauth2.*` (client issuer/clientId/
|
||||
clientSecret/scopes/useAsUsername) — currently only the `enabled` toggle is bound.
|
||||
- [ ] Serve `GET /oauth2/authorization/{registrationId}` → 302 to the IdP authorize URL (login
|
||||
initiation; the OAuth `validate` script checks this responds). Build from issuer + clientId +
|
||||
redirect-uri `/login/oauth2/code/{registrationId}`.
|
||||
- [ ] Serve the callback `GET /login/oauth2/code/{registrationId}` → exchange code (REST Client to
|
||||
the token endpoint), fetch userinfo, auto-create/login the user (reuse `CustomOAuth2UserService`
|
||||
logic), issue the app JWT via `JwtService`. `quarkus.oidc.enabled` is **build-time** and aborts
|
||||
startup with no `auth-server-url`, so either hand-roll the flow (simplest, no build-time gate)
|
||||
or enable oidc with a runtime-disabled default tenant.
|
||||
|
||||
**SAML2** (larger — no Quarkus SAML extension; OpenSAML 5 from scratch):
|
||||
- [ ] `Saml2Configuration` already loads the SP/IdP certs and computes entityId/ACS/SLO URLs and
|
||||
customizes the AuthnRequest (all preserved). Build on it:
|
||||
- [ ] `GET /saml2/service-provider-metadata/{registrationId}` → SP `EntityDescriptor` XML
|
||||
(ACS=`/login/saml2/sso/{id}`, SP signing cert) marshalled via OpenSAML 5. (The SAML `validate`
|
||||
script checks this.)
|
||||
- [ ] login initiation → build+sign an `AuthnRequest` (use `customizeAuthnRequest`) and
|
||||
redirect/POST to `samlConf.getIdpSingleLoginUrl()`.
|
||||
- [ ] `POST /login/saml2/sso/{registrationId}` (ACS) → validate the SAML response/assertion against
|
||||
the IdP cert, extract the NameID/attributes, auto-create/login the user, issue the app JWT.
|
||||
- Host these as Jakarta `@WebServlet` (quarkus-undertow) or JAX-RS resources; gate on
|
||||
`security.saml2.enabled`.
|
||||
- [ ] Both flows then feed the existing `UserSecurityIdentityAugmentor` (principal=User) once they
|
||||
establish the session/JWT.
|
||||
|
||||
### G. `saas` flavor full augmentation (optional, non-default)
|
||||
- [ ] `STIRLING_FLAVOR=saas ./gradlew :stirling-pdf:quarkusBuild` → ~28 Arc deployment problems
|
||||
(Supabase second datasource via `quarkus.datasource."supabase".*`, `SecurityFilterChain`/
|
||||
`JwtDecoder` → `quarkus.http.auth.*`+OIDC, credit `HandlerInterceptor`/`@RestControllerAdvice`
|
||||
→ JAX-RS `@Provider`/`ExceptionMapper`, `@ConfigurationProperties` → `@ConfigMapping`,
|
||||
RestTemplate → REST Client). ~90 `// TODO: Migration required` across 34 saas files.
|
||||
|
||||
### H. Test suite (unit/integration) re-enablement
|
||||
- [ ] ~180 test files are excluded from compilation (content filter on `org.springframework`/
|
||||
`com.nimbusds` imports + an explicit list in `build.gradle`). Port them to `@QuarkusTest`
|
||||
incrementally; as a file's Spring imports go away it auto-re-enters the build.
|
||||
|
||||
### I. Loose ends
|
||||
- [ ] `/q/openapi` returns 500 (`UT000048`) — known quarkus-undertow + smallrye-openapi interaction;
|
||||
swagger-ui works, live API works. Affects API-doc tooling only.
|
||||
- [ ] Jackson 2/3 convergence (drop `tools.jackson`).
|
||||
- [ ] ~437 `// TODO: Migration required` markers across the codebase document every deferred decision;
|
||||
`grep -rn "TODO: Migration required" app/*/src/main` to enumerate.
|
||||
|
||||
---
|
||||
|
||||
## 7. Quick reference — env vars used in e2e
|
||||
|
||||
| Var | Value | Why |
|
||||
|-----|-------|-----|
|
||||
| `SECURITY_ENABLELOGIN` | `false` | run without auth (most API tests); set `true` for the JWT suite |
|
||||
| `METRICS_ENABLED` | `true` | enables `/api/v1/info/*` (info.feature) |
|
||||
| `SYSTEM_DEFAULTLOCALE` | `en-US` | matches default-language change |
|
||||
| `SYSTEM_MAXFILESIZE` | `100` | upload limit for tests |
|
||||
| `QUARKUS_HTTP_PORT` | `8080` | cucumber steps hardcode 8080 |
|
||||
| `QUARKUS_DATASOURCE_JDBC_URL` | `jdbc:h2:mem:...` | use a fresh in-mem DB for clean runs (avoids stale H2 file lock) |
|
||||
|
||||
Default datasource (in `application.properties`) is **H2 file** at
|
||||
`./configs/stirling-pdf-DB-2.3.232` — fine in a container; for repeated host runs override to
|
||||
`jdbc:h2:mem:...` to dodge the file lock (`Database may be already in use`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Useful diagnostic one-liners
|
||||
|
||||
```bash
|
||||
# container alive + real error (strip ANSI, drop known background noise)
|
||||
docker logs sp-e2e 2>&1 | sed 's/\x1b\[[0-9;]*m//g' \
|
||||
| grep -iE "ERROR|Caused by|Exception" \
|
||||
| grep -viE "Log4j|LogManager|ForkJoinPool|FolderWatch|ScheduleTrigger|policy-" | tail -30
|
||||
|
||||
# categorize cucumber failures
|
||||
cd testing/cucumber && TEST_CONTAINER_NAME=sp-e2e python -m behave --no-capture --format plain --no-skipped > /tmp/behave.txt 2>&1
|
||||
grep -oE "Expected status code [0-9]+ but got [0-9]+" /tmp/behave.txt | sort | uniq -c | sort -rn
|
||||
grep -oE "features/[a-z_]+\.feature" /tmp/behave.txt | sort | uniq -c | sort -rn # rough; use a junit reporter for precise
|
||||
|
||||
# what still touches the servlet request
|
||||
grep -rln "HttpServletRequest" app/*/src/main --include=*.java
|
||||
|
||||
# enumerate deferred work
|
||||
grep -rn "TODO: Migration required" app/*/src/main --include=*.java | wc -l
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Measured cucumber results — newest first
|
||||
|
||||
**Run 5 — LOGIN ON (`SECURITY_ENABLELOGIN=true V2=true STORAGE_ENABLED=true
|
||||
SECURITY_CUSTOMGLOBALAPIKEY=123456789`):**
|
||||
```
|
||||
18 features passed, 7 failed, 0 skipped
|
||||
304 scenarios passed, 34 failed, 0 skipped <-- folders + user-scoped features now pass
|
||||
```
|
||||
X-API-KEY mechanism + User-principal augmentor + config overlay made login-ON work without
|
||||
regressing the open endpoints (0 folder failures). Trajectory: **183 → 223 → 272 → 291 → 304**.
|
||||
|
||||
**Run 4 — login off + lockout fix:** `291 passed, 47 failed, 0 skipped`.
|
||||
|
||||
**Run 3 — login off + `V2=true` + JWT Bearer mechanism (Session 2):**
|
||||
```
|
||||
17 features passed, 8 failed, 0 skipped
|
||||
272 scenarios passed, 66 failed, 0 skipped <-- 0 skipped: all JWT/admin scenarios now run
|
||||
```
|
||||
The JWT mechanism unskipped all 80 and added +49 passing over run 2 with no regressions. Remaining
|
||||
66 failures, biggest buckets:
|
||||
- **~38 = login-lockout cascade (FIXED, pending re-measure).** `loginAttemptCount` defaulted to 0
|
||||
(template = 5) → admin locked after one failed-login test → every later admin scenario blocked
|
||||
("Admin login failed" 21×, "Folder list returned" 17×). Fixed the primitive default (same as
|
||||
maxDPI). **Re-run to confirm; expect ~300+.**
|
||||
- 10×(200→403) feature-gated/disabled (mostly not bugs).
|
||||
- 5×(200→401) + 2×(401→403) — auth scenarios asserting specific codes; triage individually.
|
||||
- 3×(200→500) — real per-endpoint bugs (e.g. `user/get-api-key`). Triage via container logs.
|
||||
|
||||
**Run 2 — login off, Session 2 fixes, no JWT mechanism:**
|
||||
```
|
||||
16 features passed, 5 failed, 4 skipped
|
||||
223 scenarios passed, 35 failed, 80 skipped
|
||||
```
|
||||
|
||||
**Run 1 — original baseline (login off):**
|
||||
```
|
||||
183 scenarios passed, 75 failed, 80 skipped
|
||||
```
|
||||
|
||||
Trajectory this session: **183 → 223 (boot/login/test fixes) → 272 (JWT mechanism), 0 skipped.**
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"allowedLicenses": [
|
||||
{
|
||||
"moduleName": "org.jboss:jboss-transaction-spi",
|
||||
"moduleLicense": "Public Domain"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "BSD License"
|
||||
|
||||
+34
-7
@@ -1,7 +1,4 @@
|
||||
// Configure bootRun to disable it or point to a main class
|
||||
bootRun {
|
||||
enabled = false
|
||||
}
|
||||
// REMOVED: bootRun{enabled=false} - Spring Boot plugin task. :common is a Quarkus library module.
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
@@ -30,8 +27,29 @@ spotless {
|
||||
}
|
||||
dependencies {
|
||||
api 'com.google.guava:guava:33.6.0-jre'
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
|
||||
// spring-boot-starter-webmvc -> Quarkus REST stack (api-scoped so downstream modules inherit it).
|
||||
api 'io.quarkus:quarkus-rest'
|
||||
api 'io.quarkus:quarkus-rest-jackson'
|
||||
// Servlet bridge: large amounts of controller/filter code use jakarta.servlet (HttpServletRequest,
|
||||
// Filter, etc.). quarkus-undertow provides a servlet container on Quarkus so that API resolves and
|
||||
// runs. TODO: Migration required - longer term, port servlet usage to JAX-RS (ContainerRequestContext)
|
||||
// and drop quarkus-undertow.
|
||||
api 'io.quarkus:quarkus-undertow'
|
||||
// Bean Validation (was transitively in spring-boot-starter-webmvc).
|
||||
api 'io.quarkus:quarkus-hibernate-validator'
|
||||
// @Scheduled support (was spring-context scheduling). quarkus-scheduler manages its own
|
||||
// executor; the former SchedulingConfig TaskScheduler bean is no longer needed.
|
||||
api 'io.quarkus:quarkus-scheduler'
|
||||
// BCrypt implementation backing the Spring Security PasswordEncoder compatibility shim
|
||||
// (replaces spring-security-crypto's BCryptPasswordEncoder). Standalone, no framework.
|
||||
api 'at.favre.lib:bcrypt:0.10.2'
|
||||
// Swagger/OpenAPI annotations (io.swagger.v3.oas.annotations.*) used by common's API marker
|
||||
// interfaces; was transitive via springdoc. Quarkus' SmallRye OpenAPI also understands these.
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46'
|
||||
// REMOVED: spring-boot-starter-aspectj. Quarkus has no AspectJ weaving; quarkus-arc provides
|
||||
// CDI interceptors (@AroundInvoke / interceptor bindings) instead.
|
||||
// TODO: Migration required - any @Aspect/@Around advice must be rewritten as CDI interceptors.
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1'
|
||||
api 'com.fathzer:javaluator:3.0.6'
|
||||
api 'com.posthog.java:posthog:1.2.0'
|
||||
@@ -45,7 +63,8 @@ dependencies {
|
||||
api 'com.github.junrar:junrar:7.5.10' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
|
||||
// springdoc-openapi-starter-webmvc-ui -> SmallRye OpenAPI (schema at /q/openapi, UI at /q/swagger-ui)
|
||||
api 'io.quarkus:quarkus-smallrye-openapi'
|
||||
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
|
||||
api 'org.simplejavamail:simple-java-mail:8.12.6'
|
||||
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
|
||||
@@ -78,6 +97,14 @@ dependencies {
|
||||
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.2"
|
||||
}
|
||||
|
||||
// Jackson 3 (tools.jackson) - retained because ~100 files migrated to the Jackson 3 namespace
|
||||
// under Spring Boot 4. Quarkus integrates Jackson 2 for REST bodies; Jackson 3 coexists here as a
|
||||
// plain library so those files compile and can still build/parse JSON directly.
|
||||
// api-scoped so downstream modules (core, proprietary, saas) that import tools.jackson inherit it.
|
||||
// TODO: Migration required - converge the codebase on a single Jackson major version.
|
||||
api 'tools.jackson.core:jackson-databind:3.0.0'
|
||||
api 'tools.jackson.core:jackson-core:3.0.0'
|
||||
|
||||
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
|
||||
|
||||
|
||||
@@ -6,15 +6,16 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Named;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class EndpointConfiguration {
|
||||
|
||||
@@ -52,9 +53,10 @@ public class EndpointConfiguration {
|
||||
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
|
||||
private final boolean runningProOrHigher;
|
||||
|
||||
@Inject
|
||||
public EndpointConfiguration(
|
||||
ApplicationProperties applicationProperties,
|
||||
@Qualifier("runningProOrHigher") boolean runningProOrHigher) {
|
||||
@Named("runningProOrHigher") boolean runningProOrHigher) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.runningProOrHigher = runningProOrHigher;
|
||||
init();
|
||||
|
||||
@@ -9,7 +9,8 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -47,7 +48,7 @@ import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
|
||||
* <li>Rotated tables (90°/270° pages) may produce incorrect bounds.
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class TabulaTableParser implements TableParser {
|
||||
|
||||
|
||||
+29
-14
@@ -2,21 +2,27 @@ package stirling.software.common.annotations;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
|
||||
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||
|
||||
import jakarta.enterprise.util.Nonbinding;
|
||||
import jakarta.interceptor.InterceptorBinding;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
|
||||
/**
|
||||
* Shortcut for a POST endpoint that is executed through the Stirling "auto‑job" framework.
|
||||
*
|
||||
* <p>MIGRATION (Spring -> Quarkus): this was a Spring composed meta-annotation that stamped
|
||||
* {@code @RequestMapping(method=POST)} onto the target via {@code @AliasFor}. JAX-RS does not
|
||||
* honour {@code @Path}/{@code @POST}/{@code @Consumes} through meta-annotations, so this annotation
|
||||
* no longer provides routing. It is now a CDI {@link InterceptorBinding} handled by {@code
|
||||
* AutoJobInterceptor}. <b>Controllers using {@code @AutoJobPostMapping} must additionally declare
|
||||
* their own JAX-RS {@code @POST} + {@code @Path(value)} + {@code @Consumes(consumes)}.</b> The
|
||||
* {@link #value()}/{@link #consumes()} members are retained so a scanner/controller can read the
|
||||
* intended routing.
|
||||
*
|
||||
* <p>Behaviour notes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>The endpoint is registered with {@code POST} and, by default, consumes {@code
|
||||
* multipart/form-data} unless you override {@link #consumes()}.
|
||||
* <li>When the client supplies {@code ?async=true} the call is handed to {@link
|
||||
* stirling.software.common.service.JobExecutorService JobExecutorService} where it may be
|
||||
* queued, retried, tracked and subject to time‑outs. For synchronous (default) invocations
|
||||
@@ -26,22 +32,26 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||
* GET /api/v1/general/job/{id}</code>.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Unless stated otherwise an attribute only affects <em>async</em> execution.
|
||||
* <p>Unless stated otherwise an attribute only affects <em>async</em> execution. All members are
|
||||
* {@code @Nonbinding} so the single {@code AutoJobInterceptor} matches every annotated method; the
|
||||
* interceptor reads the actual values reflectively from the target method.
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@RequestMapping(method = RequestMethod.POST)
|
||||
@InterceptorBinding
|
||||
@RequestBody(required = true)
|
||||
public @interface AutoJobPostMapping {
|
||||
|
||||
/** Alias for {@link RequestMapping#value} – the path mapping of the endpoint. */
|
||||
@AliasFor(annotation = RequestMapping.class, attribute = "value")
|
||||
/**
|
||||
* The path mapping of the endpoint (controllers must mirror this on a JAX-RS {@code @Path}).
|
||||
*/
|
||||
@Nonbinding
|
||||
String[] value() default {};
|
||||
|
||||
/** MIME types this endpoint accepts. Defaults to {@code multipart/form-data}. */
|
||||
@AliasFor(annotation = RequestMapping.class, attribute = "consumes")
|
||||
String[] consumes() default {MediaType.MULTIPART_FORM_DATA_VALUE};
|
||||
@Nonbinding
|
||||
String[] consumes() default {MediaType.MULTIPART_FORM_DATA};
|
||||
|
||||
/**
|
||||
* Maximum execution time in milliseconds before the job is aborted. A negative value means "use
|
||||
@@ -49,6 +59,7 @@ public @interface AutoJobPostMapping {
|
||||
*
|
||||
* <p>Only honoured when {@code async=true}.
|
||||
*/
|
||||
@Nonbinding
|
||||
long timeout() default -1;
|
||||
|
||||
/**
|
||||
@@ -57,6 +68,7 @@ public @interface AutoJobPostMapping {
|
||||
*
|
||||
* <p>Only honoured when {@code async=true}.
|
||||
*/
|
||||
@Nonbinding
|
||||
int retryCount() default 1;
|
||||
|
||||
/**
|
||||
@@ -64,6 +76,7 @@ public @interface AutoJobPostMapping {
|
||||
*
|
||||
* <p>Only honoured when {@code async=true}.
|
||||
*/
|
||||
@Nonbinding
|
||||
boolean trackProgress() default true;
|
||||
|
||||
/**
|
||||
@@ -72,6 +85,7 @@ public @interface AutoJobPostMapping {
|
||||
*
|
||||
* <p>Only honoured when {@code async=true}.
|
||||
*/
|
||||
@Nonbinding
|
||||
boolean queueable() default false;
|
||||
|
||||
/**
|
||||
@@ -82,5 +96,6 @@ public @interface AutoJobPostMapping {
|
||||
* AutoJobPostMappingWeightTest} fails the build if any endpoint leaves it unset. Runtime
|
||||
* readers clamp the value into {@code [1, 100]}.
|
||||
*/
|
||||
@Nonbinding
|
||||
int resourceWeight() default Integer.MIN_VALUE;
|
||||
}
|
||||
|
||||
+3
-5
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/account")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/account").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Account Security",
|
||||
description =
|
||||
|
||||
@@ -5,19 +5,20 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
* Combined annotation for Admin Settings API controllers.
|
||||
* Includes @RestController, @RequestMapping("/api/v1/admin/settings"), and OpenAPI @Tag.
|
||||
*
|
||||
* <p>MIGRATION (Spring -> JAX-RS): JAX-RS/RESTEasy does NOT process {@code @Path} via custom
|
||||
* meta-annotations (Spring honoured composed {@code @RestController}/{@code @RequestMapping}
|
||||
* through {@code @AliasFor}; JAX-RS has no equivalent). This annotation therefore now carries only
|
||||
* the OpenAPI {@code @Tag}. Each controller annotated with {@code @AdminApi} MUST additionally
|
||||
* declare its own {@code @jakarta.ws.rs.Path("/api/v1/admin/settings")} (the path the removed
|
||||
* {@code @RequestMapping} used to supply).
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/settings")
|
||||
@Tag(
|
||||
name = "Admin Settings",
|
||||
description =
|
||||
|
||||
+3
-5
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/server-certificate")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/admin/server-certificate").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Admin - Server Certificate",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/analysis")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/analysis").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Analysis",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/config")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/config").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Config",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/convert").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Convert",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/database")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/database").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Database",
|
||||
description =
|
||||
|
||||
+3
-5
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/database")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/admin/database").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Database Management",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/filter")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/filter").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Filter",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/general").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "General",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/info")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/info").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Info",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/invite")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/invite").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Invite",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/misc").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Misc",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/pipeline")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/pipeline").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Pipeline",
|
||||
description =
|
||||
|
||||
+3
-5
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -17,8 +14,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/proprietary/ui-data")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/proprietary/ui-data").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Proprietary UI Data",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/security")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/security").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Security",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/settings")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/settings").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Settings",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/team")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/team").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "Team",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ui-data")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/ui-data").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "UI Data",
|
||||
description =
|
||||
|
||||
@@ -5,9 +5,6 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
@@ -16,8 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/user")
|
||||
// MIGRATION (Spring->JAX-RS): controllers using this annotation must declare
|
||||
// @jakarta.ws.rs.Path("/api/v1/user").
|
||||
// JAX-RS does not honour @Path via meta-annotations, so the path is not inherited from here.
|
||||
@Tag(
|
||||
name = "User",
|
||||
description =
|
||||
|
||||
@@ -7,46 +7,101 @@ import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.*;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import io.quarkus.vertx.http.runtime.CurrentVertxRequest;
|
||||
|
||||
import jakarta.annotation.Priority;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.interceptor.AroundInvoke;
|
||||
import jakarta.interceptor.Interceptor;
|
||||
import jakarta.interceptor.InvocationContext;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.JobExecutorService;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
/**
|
||||
* MIGRATION (Spring AOP -> CDI interceptor): was an {@code @Aspect} with {@code @Around} advice on
|
||||
* {@code @AutoJobPostMapping}. Reworked into a CDI {@link Interceptor} bound by the
|
||||
* {@code @AutoJobPostMapping} {@code @InterceptorBinding}; {@code @Around}/{@code
|
||||
* ProceedingJoinPoint} became {@code @AroundInvoke}/{@link InvocationContext}.
|
||||
* {@code @Priority(20)} now meaningfully orders this interceptor (runs after lower-priority audit
|
||||
* interceptors populate MDC).
|
||||
*/
|
||||
@Interceptor
|
||||
@AutoJobPostMapping
|
||||
@Priority(20)
|
||||
@Slf4j
|
||||
@Order(20) // Lower precedence - executes AFTER audit aspects populate MDC
|
||||
public class AutoJobAspect {
|
||||
|
||||
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
|
||||
|
||||
private final JobExecutorService jobExecutorService;
|
||||
private final HttpServletRequest request;
|
||||
// Reactive-safe access to the current request. The undertow HttpServletRequest proxy throws
|
||||
// UT000048 ("No request is currently active") on RESTEasy Reactive worker threads, so query
|
||||
// params / method / path / attributes are read from the Vert.x request instead, degrading to
|
||||
// null/empty when no request is active.
|
||||
private final CurrentVertxRequest currentVertxRequest;
|
||||
private final FileStorage fileStorage;
|
||||
|
||||
@Around("@annotation(autoJobPostMapping)")
|
||||
public Object wrapWithJobExecution(
|
||||
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) throws Exception {
|
||||
// This aspect will run before any audit aspects due to @Order(0)
|
||||
@Inject
|
||||
public AutoJobAspect(
|
||||
JobExecutorService jobExecutorService,
|
||||
CurrentVertxRequest currentVertxRequest,
|
||||
FileStorage fileStorage) {
|
||||
this.jobExecutorService = jobExecutorService;
|
||||
this.currentVertxRequest = currentVertxRequest;
|
||||
this.fileStorage = fileStorage;
|
||||
}
|
||||
|
||||
private io.vertx.core.http.HttpServerRequest vertxRequest() {
|
||||
try {
|
||||
var current = currentVertxRequest.getCurrent();
|
||||
return current != null ? current.request() : null;
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String requestParam(String name) {
|
||||
io.vertx.core.http.HttpServerRequest req = vertxRequest();
|
||||
return req != null ? req.getParam(name) : null;
|
||||
}
|
||||
|
||||
private String requestMethod() {
|
||||
io.vertx.core.http.HttpServerRequest req = vertxRequest();
|
||||
return req != null ? req.method().name() : "";
|
||||
}
|
||||
|
||||
private String requestUri() {
|
||||
io.vertx.core.http.HttpServerRequest req = vertxRequest();
|
||||
return req != null ? req.path() : "";
|
||||
}
|
||||
|
||||
private Object requestAttribute(String name) {
|
||||
try {
|
||||
var current = currentVertxRequest.getCurrent();
|
||||
return current != null ? current.get(name) : null;
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@AroundInvoke
|
||||
public Object wrapWithJobExecution(InvocationContext ctx) throws Exception {
|
||||
AutoJobPostMapping autoJobPostMapping =
|
||||
ctx.getMethod().getAnnotation(AutoJobPostMapping.class);
|
||||
// Extract parameters from the request and annotation
|
||||
boolean async = Boolean.parseBoolean(request.getParameter("async"));
|
||||
boolean async = Boolean.parseBoolean(requestParam("async"));
|
||||
log.debug(
|
||||
"AutoJobAspect: Processing {} {} with async={}",
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
requestMethod(),
|
||||
requestUri(),
|
||||
async);
|
||||
long timeout = autoJobPostMapping.timeout();
|
||||
int retryCount = autoJobPostMapping.retryCount();
|
||||
@@ -61,7 +116,8 @@ public class AutoJobAspect {
|
||||
trackProgress);
|
||||
|
||||
// Process arguments in-place to avoid type mismatch issues
|
||||
Object[] args = processArgsInPlace(joinPoint.getArgs(), async);
|
||||
Object[] args = processArgsInPlace(ctx.getParameters(), async);
|
||||
ctx.setParameters(args);
|
||||
|
||||
// Extract queueable and resourceWeight parameters and validate
|
||||
boolean queueable = autoJobPostMapping.queueable();
|
||||
@@ -80,7 +136,7 @@ public class AutoJobAspect {
|
||||
// The trackProgress flag controls whether detailed progress is
|
||||
// stored
|
||||
// for REST API queries, not WebSocket notifications
|
||||
return joinPoint.proceed(args);
|
||||
return ctx.proceed();
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
@@ -101,7 +157,7 @@ public class AutoJobAspect {
|
||||
} else {
|
||||
// Use retry logic
|
||||
return executeWithRetries(
|
||||
joinPoint,
|
||||
ctx,
|
||||
args,
|
||||
async,
|
||||
timeout,
|
||||
@@ -113,7 +169,7 @@ public class AutoJobAspect {
|
||||
}
|
||||
|
||||
private Object executeWithRetries(
|
||||
ProceedingJoinPoint joinPoint,
|
||||
InvocationContext ctx,
|
||||
Object[] args,
|
||||
boolean async,
|
||||
long timeout,
|
||||
@@ -158,7 +214,7 @@ public class AutoJobAspect {
|
||||
}
|
||||
|
||||
// Attempt to execute the operation
|
||||
return joinPoint.proceed(args);
|
||||
return ctx.proceed();
|
||||
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
@@ -292,7 +348,7 @@ public class AutoJobAspect {
|
||||
|
||||
private String getJobIdFromContext() {
|
||||
try {
|
||||
return (String) request.getAttribute("jobId");
|
||||
return (String) requestAttribute("jobId");
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not retrieve job ID from context: {}", e.getMessage());
|
||||
return null;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -19,7 +18,7 @@ import stirling.software.common.model.ApplicationProperties.Cluster;
|
||||
* single-instance install needs no new config.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ApplicationScoped
|
||||
@RequiredArgsConstructor
|
||||
public class ClusterConfig {
|
||||
|
||||
@@ -47,7 +46,7 @@ public class ClusterConfig {
|
||||
+ " JVM is coordinated. Cross-node lookups and the file proxy will fail."
|
||||
+ " Use backplane=valkey for real multi-node deployments.");
|
||||
} else {
|
||||
// Fail fast on typos like "valky" so Spring doesn't later report a cryptic
|
||||
// Fail fast on typos like "valky" so CDI doesn't later report a cryptic
|
||||
// "no ClusterBackplane bean" - the operator-facing error names the bad value.
|
||||
throw new IllegalStateException(
|
||||
"cluster.enabled=true with unknown backplane '"
|
||||
|
||||
+32
-20
@@ -1,9 +1,9 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import io.quarkus.arc.DefaultBean;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Produces;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -19,46 +19,58 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
* Default cluster backplane wiring: every interface gets an {@code InProcess*} bean. Active when
|
||||
* cluster mode is off or {@code cluster.backplane=inprocess}.
|
||||
*/
|
||||
// TODO: Migration required - the original @ConditionalOnExpression
|
||||
// ("!${cluster.enabled:false} || '${cluster.backplane:inprocess}'.equalsIgnoreCase('inprocess')")
|
||||
// gated activation of this whole configuration on a SpEL expression over two config properties.
|
||||
// Quarkus/CDI has no direct equivalent for conditionally registering a producer set based on a
|
||||
// SpEL boolean. The @DefaultBean producers below now always provide the in-process implementations
|
||||
// unless another bean of the same type is present. If a non-inprocess backplane is added, ensure
|
||||
// it is NOT a @DefaultBean so it wins, and consider gating with
|
||||
// @io.quarkus.arc.lookup.LookupIfProperty
|
||||
// / @io.quarkus.arc.lookup.LookupUnlessProperty or a build-time @IfBuildProperty per producer.
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ConditionalOnExpression(
|
||||
"!${cluster.enabled:false} ||"
|
||||
+ " '${cluster.backplane:inprocess}'.equalsIgnoreCase('inprocess')")
|
||||
@ApplicationScoped
|
||||
public class InProcessClusterConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@Produces
|
||||
@DefaultBean
|
||||
@ApplicationScoped
|
||||
public ClusterBackplane clusterBackplane(ApplicationProperties applicationProperties) {
|
||||
log.info("Cluster backplane: in-process (single node)");
|
||||
return new InProcessClusterBackplane(applicationProperties);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@Produces
|
||||
@DefaultBean
|
||||
@ApplicationScoped
|
||||
public JobStore jobStore() {
|
||||
return new InProcessJobStore();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@Produces
|
||||
@DefaultBean
|
||||
@ApplicationScoped
|
||||
public RateLimitStore rateLimitStore() {
|
||||
return new InProcessRateLimitStore();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@Produces
|
||||
@DefaultBean
|
||||
@ApplicationScoped
|
||||
public DistributedLock distributedLock() {
|
||||
return new InProcessDistributedLock();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@Produces
|
||||
@DefaultBean
|
||||
@ApplicationScoped
|
||||
public KeyValueCache keyValueCache() {
|
||||
return new InProcessKeyValueCache();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@Produces
|
||||
@DefaultBean
|
||||
@ApplicationScoped
|
||||
public InstanceRegistry instanceRegistry() {
|
||||
return new InProcessInstanceRegistry();
|
||||
}
|
||||
|
||||
+21
-14
@@ -1,10 +1,11 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import io.quarkus.arc.DefaultBean;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Produces;
|
||||
|
||||
import stirling.software.common.cluster.FileStore;
|
||||
|
||||
@@ -13,17 +14,23 @@ import stirling.software.common.cluster.FileStore;
|
||||
* cluster.artifactStore=local} (the default; {@code matchIfMissing=true}). The S3 artifact-store
|
||||
* supplies its own bean when {@code cluster.artifactStore=s3}.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(
|
||||
prefix = "cluster",
|
||||
name = "artifactStore",
|
||||
havingValue = "local",
|
||||
matchIfMissing = true)
|
||||
// TODO: Migration required - the original class was guarded by Spring's
|
||||
// @ConditionalOnProperty(prefix="cluster", name="artifactStore", havingValue="local",
|
||||
// matchIfMissing=true). Quarkus has no runtime equivalent: @io.quarkus.arc.profile.IfBuildProperty
|
||||
// is build-time only and does not support matchIfMissing semantics. The producer below is now
|
||||
// unconditional. The "local is the default; S3 supplies its own bean" behavior is preserved via
|
||||
// @DefaultBean (the S3 artifact-store bean, if present, wins over this default). If a true
|
||||
// runtime toggle on cluster.artifactStore is needed, gate the producer body on the config value
|
||||
// and return/short-circuit accordingly.
|
||||
@ApplicationScoped
|
||||
public class LocalDiskFileStoreConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public FileStore fileStore(@Value("${stirling.tempDir:/tmp/stirling-files}") String tempDir) {
|
||||
@Produces
|
||||
@DefaultBean
|
||||
@ApplicationScoped
|
||||
public FileStore fileStore(
|
||||
@ConfigProperty(name = "stirling.tempDir", defaultValue = "/tmp/stirling-files")
|
||||
String tempDir) {
|
||||
return new LocalDiskFileStore(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-14
@@ -3,37 +3,29 @@ package stirling.software.common.config;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Configuration for the temporary file management system. Sets up the necessary beans and
|
||||
* configures system properties.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ApplicationScoped
|
||||
@RequiredArgsConstructor
|
||||
public class TempFileConfiguration {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Create the TempFileRegistry bean.
|
||||
*
|
||||
* @return A new TempFileRegistry instance
|
||||
*/
|
||||
@Bean
|
||||
public TempFileRegistry tempFileRegistry() {
|
||||
return new TempFileRegistry();
|
||||
}
|
||||
// MIGRATION: the @Produces TempFileRegistry producer was removed. TempFileRegistry is already
|
||||
// an
|
||||
// @ApplicationScoped CDI bean with a no-arg constructor, so the producer was a redundant second
|
||||
// @Default bean of the same type and made every injection point ambiguous.
|
||||
|
||||
@PostConstruct
|
||||
public void initTempFileConfig() {
|
||||
|
||||
@@ -5,8 +5,8 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.stereotype.Component;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -14,12 +14,12 @@ import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Handles cleanup of temporary files on application shutdown. Implements Spring's DisposableBean
|
||||
* interface to ensure cleanup happens during normal application shutdown.
|
||||
* Handles cleanup of temporary files on application shutdown. Uses a CDI {@code @PreDestroy} method
|
||||
* (migrated from Spring's {@code DisposableBean}) to ensure cleanup happens during normal shutdown.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class TempFileShutdownHook implements DisposableBean {
|
||||
@ApplicationScoped
|
||||
public class TempFileShutdownHook {
|
||||
|
||||
private final TempFileRegistry registry;
|
||||
|
||||
@@ -31,8 +31,8 @@ public class TempFileShutdownHook implements DisposableBean {
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(this::cleanupTempFiles));
|
||||
}
|
||||
|
||||
/** Spring's DisposableBean interface method. Called during normal application shutdown. */
|
||||
@Override
|
||||
/** CDI pre-destroy callback (was DisposableBean#destroy). Called during normal shutdown. */
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
log.info("Application shutting down, cleaning up temporary files");
|
||||
cleanupTempFiles();
|
||||
|
||||
@@ -9,40 +9,69 @@ import java.util.Properties;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.context.annotation.Scope;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.util.ClassUtils;
|
||||
import org.eclipse.microprofile.config.Config;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import io.quarkus.arc.profile.IfBuildProfile;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.context.Dependent;
|
||||
import jakarta.enterprise.inject.Produces;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Named;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Lazy
|
||||
/**
|
||||
* Central CDI producer hub (migrated from a Spring {@code @Configuration} class).
|
||||
*
|
||||
* <p>MIGRATION NOTES (Spring -> Quarkus CDI):
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code @Bean} -> {@code @Produces}; {@code @Bean(name="x")} ->
|
||||
* {@code @Produces @Named("x")}.
|
||||
* <li>{@code @Value} -> {@code @ConfigProperty}; Spring {@code Environment} -> MicroProfile
|
||||
* {@code Config}.
|
||||
* <li>{@code @Profile("default")} flavor-default beans -> {@code @DefaultBean}: the :proprietary
|
||||
* / :saas modules provide the "real" producer and automatically win when present, exactly
|
||||
* like the old profile override (this is the Quarkus idiom for "default unless overridden").
|
||||
* <li>{@code @Scope("request")} on {@code boolean} producers -> {@code @Dependent}. CDI normal
|
||||
* scopes (e.g. {@code @RequestScoped}) require a client proxy, which is impossible for
|
||||
* primitives/finals, so Spring's request-scoped primitive beans cannot be reproduced
|
||||
* directly. {@code @Dependent} recomputes the value at each injection point, which is the
|
||||
* closest behaviour. TODO: Migration required - if true per-HTTP-request semantics are
|
||||
* needed, wrap the value in a {@code @RequestScoped} holder object instead of producing a
|
||||
* bare boolean.
|
||||
* <li>{@code @Lazy} dropped - CDI beans are initialised lazily by default.
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@ApplicationScoped
|
||||
public class AppConfig {
|
||||
|
||||
private final Environment env;
|
||||
private final Config config;
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Getter
|
||||
@Value("${server.servlet.context-path:/}")
|
||||
private String contextPath;
|
||||
@ConfigProperty(name = "server.servlet.context-path", defaultValue = "/")
|
||||
String contextPath;
|
||||
|
||||
@Getter
|
||||
@Value("${server.port:8080}")
|
||||
private String serverPort;
|
||||
@ConfigProperty(name = "quarkus.http.port", defaultValue = "8080")
|
||||
String serverPort;
|
||||
|
||||
@ConfigProperty(name = "v2")
|
||||
boolean v2Enabled;
|
||||
|
||||
@Inject
|
||||
public AppConfig(Config config, ApplicationProperties applicationProperties) {
|
||||
this.config = config;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backend URL from system configuration. Falls back to http://localhost if not
|
||||
@@ -55,76 +84,111 @@ public class AppConfig {
|
||||
return (backendUrl != null && !backendUrl.isBlank()) ? backendUrl : "http://localhost";
|
||||
}
|
||||
|
||||
@Value("${v2}")
|
||||
public boolean v2Enabled;
|
||||
|
||||
@Bean
|
||||
@Produces
|
||||
@Named("v2Enabled")
|
||||
public boolean v2Enabled() {
|
||||
return v2Enabled;
|
||||
}
|
||||
|
||||
@Bean(name = "loginEnabled")
|
||||
// MIGRATION: many beans inject tools.jackson.databind.ObjectMapper (Jackson 3, inherited from
|
||||
// Spring Boot 4). Quarkus' container only produces a com.fasterxml.jackson (Jackson 2)
|
||||
// ObjectMapper for REST (de)serialization, so the Jackson 3 type is an unsatisfied CDI
|
||||
// dependency. This producer supplies a single application-scoped Jackson 3 mapper built the
|
||||
// same
|
||||
// way the codebase builds them ad hoc (JsonMapper.builder().build()). REST bodies still go
|
||||
// through Quarkus' Jackson 2 mapper; this is only for code that uses the Jackson 3 API
|
||||
// directly.
|
||||
// TODO: Migration required - converge the codebase on one Jackson line (drop Jackson 3) later.
|
||||
@Produces
|
||||
@ApplicationScoped
|
||||
public tools.jackson.databind.ObjectMapper jackson3ObjectMapper() {
|
||||
return tools.jackson.databind.json.JsonMapper.builder().build();
|
||||
}
|
||||
|
||||
@Produces
|
||||
@Named("contextPath")
|
||||
public String contextPathBean() {
|
||||
return contextPath;
|
||||
}
|
||||
|
||||
@Produces
|
||||
@Named("loginEnabled")
|
||||
public boolean loginEnabled() {
|
||||
return applicationProperties.getSecurity().isEnableLogin();
|
||||
}
|
||||
|
||||
@Bean(name = "appName")
|
||||
// MIGRATION: CDI has no producer for the nested ApplicationProperties.Security.SAML2 config
|
||||
// object, so beans that inject it directly (e.g. CustomSaml2AuthenticationSuccessHandler) were
|
||||
// unsatisfied. Expose it from the already-injected ApplicationProperties. May be null/disabled;
|
||||
// that is fine for injection.
|
||||
@Produces
|
||||
public ApplicationProperties.Security.SAML2 saml2Config() {
|
||||
return applicationProperties.getSecurity().getSaml2();
|
||||
}
|
||||
|
||||
@Produces
|
||||
@Named("appName")
|
||||
public String appName() {
|
||||
return "Stirling PDF";
|
||||
}
|
||||
|
||||
@Bean(name = "appVersion")
|
||||
@Produces
|
||||
@Named("appVersion")
|
||||
public String appVersion() {
|
||||
Resource resource = new ClassPathResource("version.properties");
|
||||
// MIGRATION: Spring ClassPathResource -> plain classloader resource lookup.
|
||||
Properties props = new Properties();
|
||||
try {
|
||||
props.load(resource.getInputStream());
|
||||
return props.getProperty("version");
|
||||
try (var in = getClass().getClassLoader().getResourceAsStream("version.properties")) {
|
||||
if (in != null) {
|
||||
props.load(in);
|
||||
return props.getProperty("version");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("exception", e);
|
||||
}
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
@Bean(name = "homeText")
|
||||
@Produces
|
||||
@Named("homeText")
|
||||
public String homeText() {
|
||||
return "null";
|
||||
}
|
||||
|
||||
@Bean(name = "languages")
|
||||
@Produces
|
||||
@Named("languages")
|
||||
public List<String> languages() {
|
||||
return applicationProperties.getUi().getLanguages();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public String contextPath(@Value("${server.servlet.context-path}") String contextPath) {
|
||||
return contextPath;
|
||||
}
|
||||
|
||||
@Bean(name = "navBarText")
|
||||
@Produces
|
||||
@Named("navBarText")
|
||||
public String navBarText() {
|
||||
String navBar = applicationProperties.getUi().getAppNameNavbar();
|
||||
return (navBar != null) ? navBar : "Stirling PDF";
|
||||
}
|
||||
|
||||
@Bean(name = "enableAlphaFunctionality")
|
||||
@Produces
|
||||
@Named("enableAlphaFunctionality")
|
||||
public boolean enableAlphaFunctionality() {
|
||||
return applicationProperties.getSystem().isEnableAlphaFunctionality();
|
||||
}
|
||||
|
||||
@Bean(name = "rateLimit")
|
||||
@Produces
|
||||
@Named("rateLimit")
|
||||
public boolean rateLimit() {
|
||||
String rateLimit = System.getProperty("rateLimit");
|
||||
if (rateLimit == null) rateLimit = System.getenv("rateLimit");
|
||||
return Boolean.parseBoolean(rateLimit);
|
||||
}
|
||||
|
||||
@Bean(name = "RunningInDocker")
|
||||
@Produces
|
||||
@Named("RunningInDocker")
|
||||
public boolean runningInDocker() {
|
||||
return Files.exists(Path.of("/.dockerenv"));
|
||||
}
|
||||
|
||||
@Bean(name = "configDirMounted")
|
||||
@Produces
|
||||
@Named("configDirMounted")
|
||||
public boolean isRunningInDockerWithConfig() {
|
||||
Path dockerEnv = Path.of("/.dockerenv");
|
||||
// default to true if not docker
|
||||
@@ -143,14 +207,23 @@ public class AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
@Bean(name = "activeSecurity")
|
||||
@Produces
|
||||
@Named("activeSecurity")
|
||||
public boolean missingActiveSecurity() {
|
||||
return ClassUtils.isPresent(
|
||||
"stirling.software.proprietary.security.configuration.SecurityConfiguration",
|
||||
this.getClass().getClassLoader());
|
||||
// MIGRATION: Spring ClassUtils.isPresent -> manual Class.forName presence check.
|
||||
try {
|
||||
Class.forName(
|
||||
"stirling.software.proprietary.security.configuration.SecurityConfiguration",
|
||||
false,
|
||||
this.getClass().getClassLoader());
|
||||
return true;
|
||||
} catch (ClassNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Bean(name = "directoryFilter")
|
||||
@Produces
|
||||
@Named("directoryFilter")
|
||||
public Predicate<Path> processOnlyFiles() {
|
||||
return path -> {
|
||||
if (Files.isDirectory(path)) {
|
||||
@@ -161,113 +234,138 @@ public class AppConfig {
|
||||
};
|
||||
}
|
||||
|
||||
@Bean(name = "termsAndConditions")
|
||||
@Produces
|
||||
@Named("termsAndConditions")
|
||||
public String termsAndConditions() {
|
||||
return applicationProperties.getLegal().getTermsAndConditions();
|
||||
}
|
||||
|
||||
@Bean(name = "privacyPolicy")
|
||||
@Produces
|
||||
@Named("privacyPolicy")
|
||||
public String privacyPolicy() {
|
||||
return applicationProperties.getLegal().getPrivacyPolicy();
|
||||
}
|
||||
|
||||
@Bean(name = "cookiePolicy")
|
||||
@Produces
|
||||
@Named("cookiePolicy")
|
||||
public String cookiePolicy() {
|
||||
return applicationProperties.getLegal().getCookiePolicy();
|
||||
}
|
||||
|
||||
@Bean(name = "impressum")
|
||||
@Produces
|
||||
@Named("impressum")
|
||||
public String impressum() {
|
||||
return applicationProperties.getLegal().getImpressum();
|
||||
}
|
||||
|
||||
@Bean(name = "accessibilityStatement")
|
||||
@Produces
|
||||
@Named("accessibilityStatement")
|
||||
public String accessibilityStatement() {
|
||||
return applicationProperties.getLegal().getAccessibilityStatement();
|
||||
}
|
||||
|
||||
@Bean(name = "analyticsPrompt")
|
||||
@Scope("request")
|
||||
@Produces
|
||||
@Dependent
|
||||
@Named("analyticsPrompt")
|
||||
public boolean analyticsPrompt() {
|
||||
return applicationProperties.getSystem().getEnableAnalytics() == null;
|
||||
}
|
||||
|
||||
@Bean(name = "analyticsEnabled")
|
||||
@Scope("request")
|
||||
@Produces
|
||||
@Dependent
|
||||
@Named("analyticsEnabled")
|
||||
public boolean analyticsEnabled() {
|
||||
if (applicationProperties.getPremium().isEnabled()) return true;
|
||||
return applicationProperties.getSystem().isAnalyticsEnabled();
|
||||
}
|
||||
|
||||
@Bean(name = "StirlingPDFLabel")
|
||||
@Produces
|
||||
@Named("StirlingPDFLabel")
|
||||
public String stirlingPDFLabel() {
|
||||
return "Stirling-PDF" + " v" + appVersion();
|
||||
}
|
||||
|
||||
@Bean(name = "UUID")
|
||||
@Produces
|
||||
@Named("UUID")
|
||||
public String uuid() {
|
||||
return applicationProperties.getAutomaticallyGenerated().getUUID();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Produces
|
||||
public ApplicationProperties.Security security() {
|
||||
return applicationProperties.getSecurity();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Produces
|
||||
public ApplicationProperties.Security.OAUTH2 oAuth2() {
|
||||
return applicationProperties.getSecurity().getOauth2();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Produces
|
||||
public ApplicationProperties.Premium premium() {
|
||||
return applicationProperties.getPremium();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Produces
|
||||
public ApplicationProperties.System system() {
|
||||
return applicationProperties.getSystem();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Produces
|
||||
public ApplicationProperties.Datasource datasource() {
|
||||
return applicationProperties.getSystem().getDatasource();
|
||||
}
|
||||
|
||||
@Bean(name = "runningProOrHigher")
|
||||
@Profile("default")
|
||||
// @IfBuildProfile("core"): these NORMAL/default license @Named beans apply only to the core
|
||||
// flavor. In proprietary EEAppConfig provides them (security profile) and in saas
|
||||
// SaasLicenseOverride does (saas profile); registering this producer alongside those trips
|
||||
// Qute's named-bean validation ("Duplicate key runningEE"), which does not honour @DefaultBean
|
||||
// suppression - so gate to core outright. (In core, EEAppConfig/SaasLicenseOverride are not
|
||||
// even on the classpath.)
|
||||
@Produces
|
||||
@IfBuildProfile("core")
|
||||
@Named("runningProOrHigher")
|
||||
public boolean runningProOrHigher() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "runningEE")
|
||||
@Profile("default")
|
||||
@Produces
|
||||
@IfBuildProfile("core")
|
||||
@Named("runningEE")
|
||||
public boolean runningEnterprise() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "license")
|
||||
@Profile("default")
|
||||
@Produces
|
||||
@IfBuildProfile("core")
|
||||
@Named("license")
|
||||
public String licenseType() {
|
||||
return "NORMAL";
|
||||
}
|
||||
|
||||
@Bean(name = "scarfEnabled")
|
||||
@Produces
|
||||
@Named("scarfEnabled")
|
||||
public boolean scarfEnabled() {
|
||||
return applicationProperties.getSystem().isScarfEnabled();
|
||||
}
|
||||
|
||||
@Bean(name = "posthogEnabled")
|
||||
@Produces
|
||||
@Named("posthogEnabled")
|
||||
public boolean posthogEnabled() {
|
||||
return applicationProperties.getSystem().isPosthogEnabled();
|
||||
}
|
||||
|
||||
@Bean(name = "machineType")
|
||||
@Produces
|
||||
@Named("machineType")
|
||||
public String determineMachineType() {
|
||||
try {
|
||||
boolean isDocker = runningInDocker();
|
||||
boolean isKubernetes = System.getenv("KUBERNETES_SERVICE_HOST") != null;
|
||||
boolean isBrowserOpen = "true".equalsIgnoreCase(env.getProperty("BROWSER_OPEN"));
|
||||
boolean isBrowserOpen =
|
||||
"true"
|
||||
.equalsIgnoreCase(
|
||||
config.getOptionalValue("BROWSER_OPEN", String.class)
|
||||
.orElse(null));
|
||||
|
||||
if (isKubernetes) {
|
||||
return "Kubernetes";
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.microprofile.config.Config;
|
||||
import org.eclipse.microprofile.config.ConfigProvider;
|
||||
|
||||
import io.quarkus.arc.ClientProxy;
|
||||
import io.quarkus.runtime.StartupEvent;
|
||||
|
||||
import jakarta.annotation.Priority;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.event.Observes;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.interceptor.Interceptor;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Binds MicroProfile/Quarkus config (env vars, {@code settings.yml} via {@link
|
||||
* SettingsYamlConfigSource}, {@code application.properties}, system properties) onto the mutable
|
||||
* {@link ApplicationProperties} bean at startup - the Quarkus replacement for the Spring
|
||||
* {@code @ConfigurationProperties(prefix = "")} relaxed binding that was lost in the migration.
|
||||
*
|
||||
* <p>Rather than hand-listing each property, this walks the whole {@code ApplicationProperties}
|
||||
* object graph by reflection and, for every scalar / enum / scalar-list field, applies the value
|
||||
* from config when one is present (so unset fields keep their Java default). The dotted key for a
|
||||
* field mirrors its path in the tree ({@code security.oauth2.client.keycloak.clientId}, {@code
|
||||
* endpoints.toRemove}, ...); SmallRye then resolves it from any source - e.g. env var {@code
|
||||
* SECURITY_OAUTH2_CLIENT_KEYCLOAK_CLIENTID} or the same key in {@code settings.yml} - with the
|
||||
* usual precedence (sys props > env > settings.yml > application.properties).
|
||||
*
|
||||
* <p>This is the behaviour Spring had: every settings.yml / {@code SECURITY_*}/{@code STORAGE_*}
|
||||
* /{@code PREMIUM_*} value is honoured, fixing the whole {@code maxDPI=0} / {@code enableLogin}
|
||||
* /{@code endpoints.toRemove} / premium-license class of "ignored config" bugs at once.
|
||||
*
|
||||
* <p>Runs with {@code @Priority(APPLICATION)} so it completes before startup consumers read the
|
||||
* bean: {@code InitialSecuritySetup} (enableLogin / customGlobalAPIKey), {@code
|
||||
* EndpointConfiguration} (endpoints.toRemove), and {@code LicenseKeyChecker.onApplicationReady}
|
||||
* (premium.enabled / premium.key, which has the lower default observer priority 2500).
|
||||
*
|
||||
* <p>Values are never logged - only key names at DEBUG and a total at INFO - because the tree
|
||||
* carries secrets (premium key, client secrets, initial-login password, SMTP/Telegram tokens).
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class ApplicationPropertiesConfigOverlay {
|
||||
|
||||
private static final int MAX_DEPTH = 20;
|
||||
|
||||
@Inject ApplicationProperties applicationProperties;
|
||||
|
||||
void onStart(@Observes @Priority(Interceptor.Priority.APPLICATION) StartupEvent event) {
|
||||
Config config = ConfigProvider.getConfig();
|
||||
// ApplicationProperties is @ApplicationScoped, so the injected reference is a client proxy;
|
||||
// reflect over the real contextual instance (its getters delegate, but getDeclaredFields()
|
||||
// on the proxy would not see the model fields).
|
||||
Object root = applicationProperties;
|
||||
if (root instanceof ClientProxy proxy) {
|
||||
root = proxy.arc_contextualInstance();
|
||||
}
|
||||
int[] applied = {0};
|
||||
bind(root, "", config, 0, applied);
|
||||
log.info(
|
||||
"Applied {} configuration override(s) onto ApplicationProperties"
|
||||
+ " (settings.yml + environment)",
|
||||
applied[0]);
|
||||
}
|
||||
|
||||
private void bind(Object node, String prefix, Config config, int depth, int[] applied) {
|
||||
if (node == null || depth > MAX_DEPTH) {
|
||||
return;
|
||||
}
|
||||
for (Field field : node.getClass().getDeclaredFields()) {
|
||||
int mods = field.getModifiers();
|
||||
if (Modifier.isStatic(mods) || field.isSynthetic()) {
|
||||
continue;
|
||||
}
|
||||
String key = prefix.isEmpty() ? field.getName() : prefix + "." + field.getName();
|
||||
Class<?> type = field.getType();
|
||||
try {
|
||||
field.setAccessible(true);
|
||||
if (isModelType(type)) {
|
||||
Object child = field.get(node);
|
||||
if (child == null) {
|
||||
child = instantiate(type);
|
||||
if (child != null) {
|
||||
field.set(node, child);
|
||||
}
|
||||
}
|
||||
bind(child, key, config, depth + 1, applied);
|
||||
} else if (List.class.isAssignableFrom(type)) {
|
||||
Class<?> element = listElementType(field);
|
||||
if (element != null && isLeaf(element)) {
|
||||
config.getOptionalValues(key, element)
|
||||
.ifPresent(value -> apply(field, node, value, key, applied));
|
||||
}
|
||||
// List<model-type> has no flat scalar representation here - skip.
|
||||
} else if (isLeaf(type)) {
|
||||
config.getOptionalValue(key, box(type))
|
||||
.ifPresent(value -> apply(field, node, value, key, applied));
|
||||
}
|
||||
// Maps and other container/unsupported types are left to their Java defaults.
|
||||
} catch (Exception ex) {
|
||||
// Per-field best effort: an unconvertible value or inaccessible field must not
|
||||
// abort
|
||||
// the whole overlay. Never include the value (may be a secret).
|
||||
log.debug("Skipped config binding for {} ({})", key, ex.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void apply(Field field, Object node, Object value, String key, int[] applied) {
|
||||
try {
|
||||
field.set(node, value);
|
||||
applied[0]++;
|
||||
// Key name only - the value may be a secret (license key, password, client secret).
|
||||
log.debug("Applied config override: {}", key);
|
||||
} catch (Exception ex) {
|
||||
log.debug("Failed to set {} ({})", key, ex.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isModelType(Class<?> type) {
|
||||
return type.getName().startsWith("stirling.software") && !type.isEnum();
|
||||
}
|
||||
|
||||
private static boolean isLeaf(Class<?> type) {
|
||||
return type == String.class
|
||||
|| type.isEnum()
|
||||
|| type.isPrimitive()
|
||||
|| type == Boolean.class
|
||||
|| type == Integer.class
|
||||
|| type == Long.class
|
||||
|| type == Double.class
|
||||
|| type == Float.class
|
||||
|| type == Short.class
|
||||
|| type == Byte.class;
|
||||
}
|
||||
|
||||
private static Class<?> box(Class<?> type) {
|
||||
if (!type.isPrimitive()) {
|
||||
return type;
|
||||
}
|
||||
if (type == boolean.class) {
|
||||
return Boolean.class;
|
||||
}
|
||||
if (type == int.class) {
|
||||
return Integer.class;
|
||||
}
|
||||
if (type == long.class) {
|
||||
return Long.class;
|
||||
}
|
||||
if (type == double.class) {
|
||||
return Double.class;
|
||||
}
|
||||
if (type == float.class) {
|
||||
return Float.class;
|
||||
}
|
||||
if (type == short.class) {
|
||||
return Short.class;
|
||||
}
|
||||
if (type == byte.class) {
|
||||
return Byte.class;
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
private static Class<?> listElementType(Field field) {
|
||||
Type generic = field.getGenericType();
|
||||
if (generic instanceof ParameterizedType parameterized) {
|
||||
Type[] args = parameterized.getActualTypeArguments();
|
||||
if (args.length == 1 && args[0] instanceof Class<?> element) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Object instantiate(Class<?> type) {
|
||||
try {
|
||||
return type.getDeclaredConstructor().newInstance();
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,29 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import com.posthog.java.PostHog;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Produces;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Configuration
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class PostHogConfig {
|
||||
|
||||
@Value("${posthog.api.key}")
|
||||
private String posthogApiKey;
|
||||
@ConfigProperty(name = "posthog.api.key")
|
||||
String posthogApiKey;
|
||||
|
||||
@Value("${posthog.host}")
|
||||
private String posthogHost;
|
||||
@ConfigProperty(name = "posthog.host")
|
||||
String posthogHost;
|
||||
|
||||
private PostHog postHogClient;
|
||||
|
||||
@Bean
|
||||
@Produces
|
||||
@ApplicationScoped
|
||||
public PostHog postHogClient() {
|
||||
postHogClient =
|
||||
new PostHog.Builder(posthogApiKey)
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.posthog.java.PostHogLogger;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ApplicationScoped
|
||||
public class PostHogLoggerImpl implements PostHogLogger {
|
||||
|
||||
@Override
|
||||
|
||||
+3
-2
@@ -10,7 +10,8 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -24,7 +25,7 @@ import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.UnoServerPool;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ApplicationScoped
|
||||
@Getter
|
||||
public class RuntimePathConfig {
|
||||
private final ApplicationProperties properties;
|
||||
|
||||
+12
-16
@@ -1,23 +1,19 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
|
||||
|
||||
/**
|
||||
* Configures the scheduler used by all {@code @Scheduled} methods. Uses virtual threads so that
|
||||
* long-running scheduled tasks (e.g. cleanup, license checks, file monitoring) never block each
|
||||
* other — each runs on its own lightweight virtual thread.
|
||||
*
|
||||
* <p>MIGRATION (Spring -> Quarkus): the custom Spring {@code TaskScheduler} bean has been removed.
|
||||
* Quarkus' {@code quarkus-scheduler} extension owns the scheduling thread pool, so no application
|
||||
* bean is required. To keep the "each scheduled task on its own virtual thread" behaviour, annotate
|
||||
* the individual {@code @io.quarkus.scheduler.Scheduled} methods with
|
||||
* {@code @io.smallrye.common.annotation.RunOnVirtualThread} (or configure {@code
|
||||
* quarkus.scheduler.use-virtual-threads=true} where supported).
|
||||
*
|
||||
* <p>TODO: Migration required - any injection point that received the former Spring {@code
|
||||
* TaskScheduler} bean must be rewritten to use the Quarkus scheduler API or a CDI-managed {@code
|
||||
* java.util.concurrent.ScheduledExecutorService}.
|
||||
*/
|
||||
@Configuration
|
||||
public class SchedulingConfig {
|
||||
|
||||
@Bean
|
||||
public TaskScheduler taskScheduler() {
|
||||
SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
|
||||
scheduler.setVirtualThreads(true);
|
||||
scheduler.setThreadNamePrefix("scheduled-vt-");
|
||||
return scheduler;
|
||||
}
|
||||
}
|
||||
public class SchedulingConfig {}
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.microprofile.config.spi.ConfigSource;
|
||||
import org.snakeyaml.engine.v2.api.Load;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
|
||||
/**
|
||||
* Exposes {@code settings.yml} (and {@code custom_settings.yml}, with the bundled {@code
|
||||
* settings.yml.template} as the default fallback) as a MicroProfile/SmallRye {@link ConfigSource}.
|
||||
*
|
||||
* <p>Restores the Spring {@code @ConfigurationProperties} behaviour that bound {@code settings.yml}
|
||||
* into {@code ApplicationProperties}: without this the YAML was never read under Quarkus, so flags
|
||||
* like {@code security.enableLogin} fell back to their Java defaults regardless of the file (the
|
||||
* {@code enableLogin=false}/{@code maxDPI=0}/{@code loginAttemptCount=0} class of bugs). The nested
|
||||
* YAML is flattened to dotted keys ({@code security.enableLogin -> "true"}); {@link
|
||||
* ApplicationPropertiesConfigOverlay} and {@code @ConfigProperty} injections then read them.
|
||||
*
|
||||
* <p>Ordinal {@value #ORDINAL} sits above {@code application.properties} (250) but below
|
||||
* environment variables (300) and system properties (400), matching Spring's precedence - e.g.
|
||||
* {@code SECURITY_ENABLELOGIN} still overrides the file.
|
||||
*
|
||||
* <p>Registered via {@code META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource}.
|
||||
*/
|
||||
public class SettingsYamlConfigSource implements ConfigSource {
|
||||
|
||||
private static final int ORDINAL = 275;
|
||||
|
||||
private final Map<String, String> properties;
|
||||
|
||||
public SettingsYamlConfigSource() {
|
||||
this.properties = load();
|
||||
}
|
||||
|
||||
private static Map<String, String> load() {
|
||||
Map<String, String> flat = new HashMap<>();
|
||||
// 1. Bundled template provides the defaults (e.g. security.enableLogin: true).
|
||||
try (InputStream in =
|
||||
SettingsYamlConfigSource.class
|
||||
.getClassLoader()
|
||||
.getResourceAsStream("settings.yml.template")) {
|
||||
if (in != null) {
|
||||
flatten("", loadYaml(in), flat);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// best effort - fall through to file overrides / Java defaults
|
||||
}
|
||||
// 2. The user's settings.yml overrides the template.
|
||||
mergeFile(InstallationPathConfig.getSettingsPath(), flat);
|
||||
// 3. custom_settings.yml overrides settings.yml.
|
||||
mergeFile(InstallationPathConfig.getCustomSettingsPath(), flat);
|
||||
return flat;
|
||||
}
|
||||
|
||||
private static void mergeFile(String path, Map<String, String> flat) {
|
||||
try {
|
||||
Path p = Path.of(path);
|
||||
if (Files.isRegularFile(p)) {
|
||||
try (InputStream in = Files.newInputStream(p)) {
|
||||
flatten("", loadYaml(in), flat);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// unreadable/invalid file - keep whatever defaults were already loaded
|
||||
}
|
||||
}
|
||||
|
||||
private static Object loadYaml(InputStream in) {
|
||||
return new Load(LoadSettings.builder().build()).loadFromInputStream(in);
|
||||
}
|
||||
|
||||
private static void flatten(String prefix, Object node, Map<String, String> out) {
|
||||
if (node instanceof Map<?, ?> map) {
|
||||
for (Map.Entry<?, ?> e : map.entrySet()) {
|
||||
String key =
|
||||
prefix.isEmpty() ? String.valueOf(e.getKey()) : prefix + "." + e.getKey();
|
||||
flatten(key, e.getValue(), out);
|
||||
}
|
||||
} else if (node instanceof List<?> list) {
|
||||
// Emit scalar lists as a comma-separated value so SmallRye binds them via
|
||||
// config.getValues()/getOptionalValues() (e.g. endpoints.toRemove, consumed by
|
||||
// EndpointConfiguration to disable endpoints). Lists containing maps/nested lists have
|
||||
// no
|
||||
// flat scalar form, so skip those - their consumers read them structurally, not through
|
||||
// this overlay. The scalar lists here (endpoint names, group names) contain no commas,
|
||||
// so
|
||||
// a plain join round-trips cleanly.
|
||||
boolean scalarList =
|
||||
!list.isEmpty()
|
||||
&& list.stream()
|
||||
.allMatch(
|
||||
e ->
|
||||
e != null
|
||||
&& !(e instanceof Map)
|
||||
&& !(e instanceof List));
|
||||
if (scalarList) {
|
||||
out.put(
|
||||
prefix,
|
||||
list.stream()
|
||||
.map(String::valueOf)
|
||||
.collect(java.util.stream.Collectors.joining(",")));
|
||||
}
|
||||
return;
|
||||
} else if (node != null) {
|
||||
out.put(prefix, String.valueOf(node));
|
||||
}
|
||||
// null leaves are left unset so the Java default applies.
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> getProperties() {
|
||||
return properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getPropertyNames() {
|
||||
return properties.keySet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getValue(String propertyName) {
|
||||
return properties.get(propertyName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "settings.yml";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrdinal() {
|
||||
return ORDINAL;
|
||||
}
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import java.util.Properties;
|
||||
|
||||
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
|
||||
import org.springframework.core.env.PropertiesPropertySource;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
import org.springframework.core.io.support.EncodedResource;
|
||||
import org.springframework.core.io.support.PropertySourceFactory;
|
||||
|
||||
public class YamlPropertySourceFactory implements PropertySourceFactory {
|
||||
|
||||
@Override
|
||||
public PropertySource<?> createPropertySource(String name, EncodedResource encodedResource) {
|
||||
YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
|
||||
factory.setResources(encodedResource.getResource());
|
||||
Properties properties = factory.getObject();
|
||||
|
||||
return new PropertiesPropertySource(
|
||||
encodedResource.getResource().getFilename(), properties);
|
||||
}
|
||||
}
|
||||
+43
-54
@@ -1,7 +1,6 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
@@ -15,22 +14,11 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.EncodedResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
@@ -39,9 +27,11 @@ import lombok.ToString;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.YamlPropertySourceFactory;
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.common.model.io.ClassPathResource;
|
||||
import stirling.software.common.model.io.FileSystemResource;
|
||||
import stirling.software.common.model.io.Resource;
|
||||
import stirling.software.common.model.oauth2.GitHubProvider;
|
||||
import stirling.software.common.model.oauth2.GoogleProvider;
|
||||
import stirling.software.common.model.oauth2.KeycloakProvider;
|
||||
@@ -51,9 +41,12 @@ import stirling.software.common.util.ValidationUtils;
|
||||
|
||||
@Data
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@ConfigurationProperties(prefix = "")
|
||||
@ApplicationScoped
|
||||
// TODO: Migration required - rebind via @io.smallrye.config.ConfigMapping or
|
||||
// @io.quarkus.arc.config.ConfigProperties. Was Spring @ConfigurationProperties(prefix = ""),
|
||||
// kept here as a plain CDI bean POJO; the property binding is not yet wired in Quarkus.
|
||||
// TODO: Migration required - Spring @Order(Ordered.HIGHEST_PRECEDENCE) controlled
|
||||
// configuration-bean ordering; there is no equivalent CDI ordering annotation for this bean.
|
||||
public class ApplicationProperties {
|
||||
|
||||
private Legal legal = new Legal();
|
||||
@@ -82,38 +75,17 @@ public class ApplicationProperties {
|
||||
private Cluster cluster = new Cluster();
|
||||
private Policies policies = new Policies();
|
||||
|
||||
@Bean
|
||||
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
|
||||
throws IOException {
|
||||
String configPath = InstallationPathConfig.getSettingsPath();
|
||||
log.debug("Attempting to load settings from: {}", configPath);
|
||||
|
||||
File file = new File(configPath);
|
||||
if (!file.exists()) {
|
||||
log.error("Warning: Settings file does not exist at: {}", configPath);
|
||||
}
|
||||
|
||||
Resource resource = new FileSystemResource(configPath);
|
||||
if (!resource.exists()) {
|
||||
throw new FileNotFoundException("Settings file not found at: " + configPath);
|
||||
}
|
||||
|
||||
EncodedResource encodedResource = new EncodedResource(resource);
|
||||
PropertySource<?> propertySource =
|
||||
new YamlPropertySourceFactory().createPropertySource(null, encodedResource);
|
||||
|
||||
boolean saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas");
|
||||
if (saasActive) {
|
||||
// Saas-pinned values in application-saas.properties must beat settings.yml.
|
||||
environment.getPropertySources().addLast(propertySource);
|
||||
} else {
|
||||
environment.getPropertySources().addFirst(propertySource);
|
||||
}
|
||||
|
||||
log.debug("Loaded properties: {}", propertySource.getSource());
|
||||
|
||||
return propertySource;
|
||||
}
|
||||
// REMOVED (Spring -> Quarkus): dynamicYamlPropertySource(ConfigurableEnvironment).
|
||||
// This was a Spring @Bean that registered settings.yml as an extra runtime PropertySource on
|
||||
// the
|
||||
// ConfigurableEnvironment (added first, or last under the "saas" profile). Quarkus has no
|
||||
// ConfigurableEnvironment/PropertySource model and the @Bean had already been removed, so the
|
||||
// method was dead code referencing Spring-only types.
|
||||
// TODO: Migration required - reimplement external settings.yml loading as a custom
|
||||
// org.eclipse.microprofile.config.spi.ConfigSource (registered via a ConfigSourceProvider /
|
||||
// META-INF/services), giving it an ordinal that reproduces the old precedence: higher than the
|
||||
// application defaults normally, but lower than application-saas.properties under the saas
|
||||
// profile. Wire it in ConfigInitializer.
|
||||
|
||||
/**
|
||||
* Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports
|
||||
@@ -522,8 +494,12 @@ public class ApplicationProperties {
|
||||
private InitialLogin initialLogin = new InitialLogin();
|
||||
private OAUTH2 oauth2 = new OAUTH2();
|
||||
private SAML2 saml2 = new SAML2();
|
||||
private int loginAttemptCount;
|
||||
private long loginResetTimeMinutes;
|
||||
// Defaults mirror settings.yml.template. These primitives are not bound from the template
|
||||
// by the current Quarkus config path, so an unset 0 means "lock after 0 attempts" (every
|
||||
// login blocked, and the lockout never accumulates a window) - same class of bug as
|
||||
// maxDPI=0. See the settings.yml binding TODO.
|
||||
private int loginAttemptCount = 5;
|
||||
private long loginResetTimeMinutes = 120;
|
||||
private String loginMethod = "all";
|
||||
private String customGlobalAPIKey;
|
||||
private Jwt jwt = new Jwt();
|
||||
@@ -608,8 +584,9 @@ public class ApplicationProperties {
|
||||
@JsonIgnore
|
||||
public InputStream getIdpMetadataUri() throws IOException {
|
||||
if (idpMetadataUri.startsWith("classpath:")) {
|
||||
return new ClassPathResource(idpMetadataUri.substring("classpath:".length()))
|
||||
.getInputStream();
|
||||
return getClass()
|
||||
.getClassLoader()
|
||||
.getResourceAsStream(idpMetadataUri.substring("classpath:".length()));
|
||||
}
|
||||
try {
|
||||
URI uri = new URI(idpMetadataUri);
|
||||
@@ -622,6 +599,9 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Migration required - returns org.springframework.core.io.Resource, a public
|
||||
// signature relied on by callers. Converting to InputStream/byte[]/java.nio would
|
||||
// ripple to those call sites, so the Spring Resource type is retained for now.
|
||||
@JsonIgnore
|
||||
public Resource getSpCert() {
|
||||
if (spCert == null) return null;
|
||||
@@ -632,6 +612,9 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Migration required - returns org.springframework.core.io.Resource, a public
|
||||
// signature relied on by callers. Converting to InputStream/byte[]/java.nio would
|
||||
// ripple to those call sites, so the Spring Resource type is retained for now.
|
||||
@JsonIgnore
|
||||
public Resource getIdpCert() {
|
||||
if (idpCert == null) return null;
|
||||
@@ -642,6 +625,9 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Migration required - returns org.springframework.core.io.Resource, a public
|
||||
// signature relied on by callers. Converting to InputStream/byte[]/java.nio would
|
||||
// ripple to those call sites, so the Spring Resource type is retained for now.
|
||||
@JsonIgnore
|
||||
public Resource getPrivateKey() {
|
||||
if (privateKey == null) return null;
|
||||
@@ -881,7 +867,10 @@ public class ApplicationProperties {
|
||||
private Boolean enableDesktopInstallSlide;
|
||||
private Datasource datasource;
|
||||
private boolean disableSanitize;
|
||||
private int maxDPI;
|
||||
// Default mirrors settings.yml.template (maxDPI: 500). Without an explicit default this
|
||||
// primitive is 0, which makes every DPI check (dpi > maxDPI) fail with "maximum safe limit
|
||||
// of 0" when the value is not populated from settings.
|
||||
private int maxDPI = 500;
|
||||
private boolean enableUrlToPDF;
|
||||
private Html html = new Html();
|
||||
private CustomPaths customPaths = new CustomPaths();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import stirling.software.common.model.io.Resource;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for Spring's {@code
|
||||
* org.springframework.web.multipart.MultipartFile}.
|
||||
*
|
||||
* <p>Quarkus/JAX-RS has no drop-in equivalent for the {@code MultipartFile} abstraction that the
|
||||
* service layer relies on (it exposes {@code org.jboss.resteasy.reactive.multipart.FileUpload} at
|
||||
* the REST boundary instead). To avoid rewriting the public signatures of dozens of service and
|
||||
* util methods across every module, this interface mirrors the subset of Spring's API that the
|
||||
* codebase actually uses. Controllers adapt the inbound {@code FileUpload}/{@code byte[]} to one of
|
||||
* the implementations ({@link stirling.software.common.model.multipart.ByteArrayMultipartFile},
|
||||
* {@link stirling.software.common.model.multipart.FileUploadMultipartFile}) and pass it down
|
||||
* unchanged.
|
||||
*
|
||||
* <p>TODO: Migration required - longer term, the REST boundary should standardise on {@code
|
||||
* FileUpload}/{@code @RestForm} and this shim can be retired.
|
||||
*/
|
||||
public interface MultipartFile {
|
||||
|
||||
String getName();
|
||||
|
||||
String getOriginalFilename();
|
||||
|
||||
String getContentType();
|
||||
|
||||
boolean isEmpty();
|
||||
|
||||
long getSize();
|
||||
|
||||
byte[] getBytes() throws IOException;
|
||||
|
||||
InputStream getInputStream() throws IOException;
|
||||
|
||||
/**
|
||||
* The content as a {@link Resource}. The default is a stream-backed resource; file-backed
|
||||
* implementations (e.g. {@code FileUploadMultipartFile}) override this to enable zero-copy fast
|
||||
* paths.
|
||||
*/
|
||||
default Resource getResource() {
|
||||
try {
|
||||
return new stirling.software.common.model.io.InputStreamResource(
|
||||
getInputStream(), getOriginalFilename());
|
||||
} catch (IOException e) {
|
||||
throw new java.io.UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
default void transferTo(File dest) throws IOException {
|
||||
transferTo(dest.toPath());
|
||||
}
|
||||
|
||||
default void transferTo(Path dest) throws IOException {
|
||||
try (InputStream in = getInputStream()) {
|
||||
// Spring's MultipartFile#transferTo overwrites an existing destination. Callers
|
||||
// commonly
|
||||
// pass a path from Files.createTempFile(...) (which has already created an empty file),
|
||||
// so REPLACE_EXISTING is required - a plain Files.copy would throw FileAlreadyExists.
|
||||
Files.copy(in, dest, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package stirling.software.common.model.api;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
public class GeneralFile {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package stirling.software.common.model.api;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
@@ -11,6 +8,8 @@ import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
@@ -18,7 +17,7 @@ public class PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The input PDF file",
|
||||
contentMediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
contentMediaType = "application/pdf",
|
||||
format = "binary")
|
||||
private MultipartFile fileInput;
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package stirling.software.common.model.io;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
|
||||
/** Classpath-backed {@link Resource} (migration shim for Spring's {@code ClassPathResource}). */
|
||||
public class ClassPathResource implements Resource {
|
||||
|
||||
private final String path;
|
||||
private final ClassLoader classLoader;
|
||||
|
||||
public ClassPathResource(String path) {
|
||||
this(path, ClassPathResource.class.getClassLoader());
|
||||
}
|
||||
|
||||
public ClassPathResource(String path, ClassLoader classLoader) {
|
||||
this.path = path.startsWith("/") ? path.substring(1) : path;
|
||||
this.classLoader = classLoader != null ? classLoader : ClassLoader.getSystemClassLoader();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException {
|
||||
InputStream is = classLoader.getResourceAsStream(path);
|
||||
if (is == null) {
|
||||
throw new IOException("class path resource [" + path + "] cannot be opened");
|
||||
}
|
||||
return is;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists() {
|
||||
return classLoader.getResource(path) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
int sep = path.lastIndexOf('/');
|
||||
return sep != -1 ? path.substring(sep + 1) : path;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long contentLength() throws IOException {
|
||||
try (InputStream is = getInputStream()) {
|
||||
long count = 0;
|
||||
byte[] buf = new byte[8192];
|
||||
int read;
|
||||
while ((read = is.read(buf)) != -1) {
|
||||
count += read;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public File getFile() throws IOException {
|
||||
URL url = classLoader.getResource(path);
|
||||
if (url == null || !"file".equals(url.getProtocol())) {
|
||||
throw new IOException("class path resource [" + path + "] is not a filesystem file");
|
||||
}
|
||||
return new File(url.getFile());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package stirling.software.common.model.io;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** File-backed {@link Resource} (migration shim for Spring's {@code FileSystemResource}). */
|
||||
public class FileSystemResource implements Resource {
|
||||
|
||||
private final Path path;
|
||||
|
||||
public FileSystemResource(Path path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public FileSystemResource(File file) {
|
||||
this.path = file.toPath();
|
||||
}
|
||||
|
||||
public FileSystemResource(String path) {
|
||||
this.path = Path.of(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException {
|
||||
return Files.newInputStream(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists() {
|
||||
return Files.exists(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
Path name = path.getFileName();
|
||||
return name == null ? null : name.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long contentLength() throws IOException {
|
||||
return Files.size(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFile() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public File getFile() {
|
||||
return path.toFile();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package stirling.software.common.model.io;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Stream-backed {@link Resource} (migration shim for Spring's {@code InputStreamResource}). As with
|
||||
* Spring, the stream can only be read once.
|
||||
*/
|
||||
public class InputStreamResource implements Resource {
|
||||
|
||||
private final InputStream inputStream;
|
||||
private final String filename;
|
||||
|
||||
public InputStreamResource(InputStream inputStream) {
|
||||
this(inputStream, null);
|
||||
}
|
||||
|
||||
public InputStreamResource(InputStream inputStream, String filename) {
|
||||
this.inputStream = inputStream;
|
||||
this.filename = filename;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return inputStream;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long contentLength() throws IOException {
|
||||
// Spring's InputStreamResource also cannot report length without consuming the stream.
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public File getFile() throws IOException {
|
||||
throw new IOException("InputStreamResource is not backed by a file");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package stirling.software.common.model.io;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for Spring's {@code org.springframework.core.io.Resource}.
|
||||
*
|
||||
* <p>Quarkus/Jakarta has no single {@code Resource} abstraction. Rather than rewrite the many
|
||||
* public method signatures across the codebase that accept or return {@code Resource}, this
|
||||
* interface mirrors the subset of Spring's API the codebase actually uses ({@code
|
||||
* getInputStream/exists/getFile/getFilename/contentLength/isFile}) together with the {@link
|
||||
* FileSystemResource}, {@link InputStreamResource} and {@link ClassPathResource} implementations.
|
||||
* Converting a file is then just an import swap.
|
||||
*
|
||||
* <p>TODO: Migration required - longer term, prefer {@code java.nio.file.Path} / {@code
|
||||
* InputStream} directly at the boundaries and retire this shim.
|
||||
*/
|
||||
public interface Resource {
|
||||
|
||||
InputStream getInputStream() throws IOException;
|
||||
|
||||
boolean exists();
|
||||
|
||||
String getFilename();
|
||||
|
||||
long contentLength() throws IOException;
|
||||
|
||||
/** Whether this resource is backed by a real file in the filesystem. */
|
||||
default boolean isFile() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the underlying file.
|
||||
* @throws IOException if the resource is not file-backed.
|
||||
*/
|
||||
File getFile() throws IOException;
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package stirling.software.common.model.multipart;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
import stirling.software.common.model.io.InputStreamResource;
|
||||
import stirling.software.common.model.io.Resource;
|
||||
|
||||
/**
|
||||
* In-memory {@link MultipartFile} backed by a byte array. Useful for tests and for code paths that
|
||||
* synthesize file content (migration shim - see {@link MultipartFile}).
|
||||
*/
|
||||
public class ByteArrayMultipartFile implements MultipartFile {
|
||||
|
||||
private final String name;
|
||||
private final String originalFilename;
|
||||
private final String contentType;
|
||||
private final byte[] content;
|
||||
|
||||
public ByteArrayMultipartFile(
|
||||
String name, String originalFilename, String contentType, byte[] content) {
|
||||
this.name = name;
|
||||
this.originalFilename = originalFilename;
|
||||
this.contentType = contentType;
|
||||
this.content = content != null ? content : new byte[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOriginalFilename() {
|
||||
return originalFilename;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return content.length == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSize() {
|
||||
return content.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getBytes() {
|
||||
return content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream(content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resource getResource() {
|
||||
return new InputStreamResource(new ByteArrayInputStream(content), originalFilename);
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package stirling.software.common.model.multipart;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.jboss.resteasy.reactive.multipart.FileUpload;
|
||||
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
import stirling.software.common.model.io.FileSystemResource;
|
||||
import stirling.software.common.model.io.Resource;
|
||||
|
||||
/**
|
||||
* Adapts a Quarkus REST {@link FileUpload} (the inbound multipart representation at the JAX-RS
|
||||
* boundary) to the {@link MultipartFile} migration shim, so controllers can pass uploads down to
|
||||
* the existing service layer without changing its method signatures.
|
||||
*/
|
||||
public class FileUploadMultipartFile implements MultipartFile {
|
||||
|
||||
private final FileUpload delegate;
|
||||
|
||||
public FileUploadMultipartFile(FileUpload delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
/** Null-safe factory: returns null when the upload is absent. */
|
||||
public static MultipartFile of(FileUpload upload) {
|
||||
return upload == null ? null : new FileUploadMultipartFile(upload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Null-safe factory for a multipart field that may have multiple parts under the same name.
|
||||
*
|
||||
* <p>Spring's MultipartFile binding picked the actual file part even when a client also sent a
|
||||
* plain text form field of the same name; RESTEasy Reactive's {@code @RestForm FileUpload}
|
||||
* binds the <em>first</em> part by name instead, so a stray {@code name=value} text part sent
|
||||
* before the file would shadow the upload. Prefer the part that carries a real filename (the
|
||||
* file), falling back to the last part, so such requests bind the same way they did under
|
||||
* Spring.
|
||||
*/
|
||||
public static MultipartFile of(List<FileUpload> uploads) {
|
||||
if (uploads == null || uploads.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
FileUpload chosen = null;
|
||||
for (FileUpload upload : uploads) {
|
||||
if (upload.fileName() != null && !upload.fileName().isBlank()) {
|
||||
chosen = upload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (chosen == null) {
|
||||
chosen = uploads.get(uploads.size() - 1);
|
||||
}
|
||||
return new FileUploadMultipartFile(chosen);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return delegate.name();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOriginalFilename() {
|
||||
return delegate.fileName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return delegate.contentType();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return getSize() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSize() {
|
||||
return delegate.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getBytes() throws IOException {
|
||||
return Files.readAllBytes(delegate.uploadedFile());
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException {
|
||||
return Files.newInputStream(delegate.uploadedFile());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resource getResource() {
|
||||
// File-backed: enables FileStorage's zero-copy fast path.
|
||||
return new FileSystemResource(delegate.uploadedFile());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void transferTo(Path dest) throws IOException {
|
||||
// Overwrite semantics like Spring's MultipartFile#transferTo; callers often pass a
|
||||
// Files.createTempFile(...) path that already exists, so REPLACE_EXISTING is required.
|
||||
Files.copy(
|
||||
delegate.uploadedFile(), dest, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.authentication.AbstractAuthenticationToken}.
|
||||
*
|
||||
* <p>Base implementation of {@link Authentication} holding authorities, details and an
|
||||
* authenticated flag.
|
||||
*/
|
||||
public abstract class AbstractAuthenticationToken implements Authentication {
|
||||
|
||||
private final List<GrantedAuthority> authorities;
|
||||
private Object details;
|
||||
private boolean authenticated = false;
|
||||
|
||||
protected AbstractAuthenticationToken(Collection<? extends GrantedAuthority> authorities) {
|
||||
if (authorities == null) {
|
||||
this.authorities = Collections.emptyList();
|
||||
} else {
|
||||
List<GrantedAuthority> copy = new ArrayList<>(authorities.size());
|
||||
for (GrantedAuthority authority : authorities) {
|
||||
copy.add(authority);
|
||||
}
|
||||
this.authorities = Collections.unmodifiableList(copy);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return authorities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getDetails() {
|
||||
return details;
|
||||
}
|
||||
|
||||
public void setDetails(Object details) {
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAuthenticated() {
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAuthenticated(boolean authenticated) throws IllegalArgumentException {
|
||||
this.authenticated = authenticated;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
Object principal = getPrincipal();
|
||||
if (principal instanceof UserDetails) {
|
||||
return ((UserDetails) principal).getUsername();
|
||||
}
|
||||
return principal == null ? null : principal.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code org.springframework.security.core.Authentication}.
|
||||
*
|
||||
* <p>Represents the token for an authentication request or for an authenticated principal once the
|
||||
* request has been processed.
|
||||
*/
|
||||
public interface Authentication extends Principal {
|
||||
|
||||
Collection<? extends GrantedAuthority> getAuthorities();
|
||||
|
||||
Object getCredentials();
|
||||
|
||||
Object getDetails();
|
||||
|
||||
Object getPrincipal();
|
||||
|
||||
boolean isAuthenticated();
|
||||
|
||||
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
|
||||
|
||||
@Override
|
||||
String getName();
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.core.AuthenticationException}.
|
||||
*
|
||||
* <p>Abstract superclass for all exceptions related to an {@link Authentication} object being
|
||||
* invalid for whatever reason.
|
||||
*/
|
||||
public class AuthenticationException extends RuntimeException {
|
||||
|
||||
public AuthenticationException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
|
||||
public AuthenticationException(String msg, Throwable cause) {
|
||||
super(msg, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder}.
|
||||
*
|
||||
* <p>Implementation of {@link PasswordEncoder} backed by the {@code at.favre.lib:bcrypt} library.
|
||||
*/
|
||||
public class BCryptPasswordEncoder implements PasswordEncoder {
|
||||
|
||||
private static final int DEFAULT_STRENGTH = 10;
|
||||
|
||||
private final int strength;
|
||||
|
||||
public BCryptPasswordEncoder() {
|
||||
this(DEFAULT_STRENGTH);
|
||||
}
|
||||
|
||||
public BCryptPasswordEncoder(int strength) {
|
||||
this.strength = strength;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String encode(CharSequence rawPassword) {
|
||||
if (rawPassword == null) {
|
||||
throw new IllegalArgumentException("rawPassword cannot be null");
|
||||
}
|
||||
return BCrypt.withDefaults().hashToString(strength, rawPassword.toString().toCharArray());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matches(CharSequence rawPassword, String encodedPassword) {
|
||||
if (rawPassword == null) {
|
||||
throw new IllegalArgumentException("rawPassword cannot be null");
|
||||
}
|
||||
if (encodedPassword == null || encodedPassword.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return BCrypt.verifyer()
|
||||
.verify(rawPassword.toString().toCharArray(), encodedPassword)
|
||||
.verified;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.authentication.BadCredentialsException}.
|
||||
*
|
||||
* <p>Thrown if an authentication request is rejected because the credentials are invalid.
|
||||
*/
|
||||
public class BadCredentialsException extends AuthenticationException {
|
||||
|
||||
public BadCredentialsException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
|
||||
public BadCredentialsException(String msg, Throwable cause) {
|
||||
super(msg, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code org.springframework.security.core.GrantedAuthority}.
|
||||
*
|
||||
* <p>Represents an authority granted to an {@link Authentication} object. Provided so that code
|
||||
* migrated from Spring Boot to Quarkus compiles without Spring Security on the classpath.
|
||||
*/
|
||||
public interface GrantedAuthority {
|
||||
|
||||
/**
|
||||
* Returns a textual representation of the granted authority.
|
||||
*
|
||||
* @return the authority string, never {@code null}
|
||||
*/
|
||||
String getAuthority();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.oauth2.core.user.OAuth2User}.
|
||||
*
|
||||
* <p>Represents a user {@link java.security.Principal} authenticated using OAuth 2.0 or OpenID
|
||||
* Connect.
|
||||
*/
|
||||
public interface OAuth2User {
|
||||
|
||||
Map<String, Object> getAttributes();
|
||||
|
||||
Collection<? extends GrantedAuthority> getAuthorities();
|
||||
|
||||
String getName();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.crypto.password.PasswordEncoder}.
|
||||
*
|
||||
* <p>Service interface for encoding passwords.
|
||||
*/
|
||||
public interface PasswordEncoder {
|
||||
|
||||
/** Encodes the raw password. */
|
||||
String encode(CharSequence rawPassword);
|
||||
|
||||
/** Verifies that the encoded password matches the raw password after it too is encoded. */
|
||||
boolean matches(CharSequence rawPassword, String encodedPassword);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken}.
|
||||
*
|
||||
* <p>Holds the persistent remember-me token data for a single series.
|
||||
*/
|
||||
public class PersistentRememberMeToken {
|
||||
|
||||
private final String username;
|
||||
private final String series;
|
||||
private final String tokenValue;
|
||||
private final Date date;
|
||||
|
||||
public PersistentRememberMeToken(String username, String series, String tokenValue, Date date) {
|
||||
this.username = username;
|
||||
this.series = series;
|
||||
this.tokenValue = tokenValue;
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getSeries() {
|
||||
return series;
|
||||
}
|
||||
|
||||
public String getTokenValue() {
|
||||
return tokenValue;
|
||||
}
|
||||
|
||||
public Date getDate() {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.web.authentication.rememberme.PersistentTokenRepository}.
|
||||
*
|
||||
* <p>Persists the remember-me tokens used by the persistent token based remember-me services.
|
||||
*/
|
||||
public interface PersistentTokenRepository {
|
||||
|
||||
void createNewToken(PersistentRememberMeToken token);
|
||||
|
||||
void updateToken(String series, String tokenValue, Date lastUsed);
|
||||
|
||||
PersistentRememberMeToken getTokenForSeries(String seriesId);
|
||||
|
||||
void removeUserTokens(String username);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.core.context.SecurityContext}.
|
||||
*
|
||||
* <p>Holds the {@link Authentication} associated with the current execution.
|
||||
*/
|
||||
public interface SecurityContext {
|
||||
|
||||
Authentication getAuthentication();
|
||||
|
||||
void setAuthentication(Authentication authentication);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.core.context.SecurityContextHolder}.
|
||||
*
|
||||
* <p>Associates a {@link SecurityContext} with the current thread of execution using a {@link
|
||||
* ThreadLocal}.
|
||||
*/
|
||||
public final class SecurityContextHolder {
|
||||
|
||||
private static final ThreadLocal<SecurityContext> CONTEXT_HOLDER = new ThreadLocal<>();
|
||||
|
||||
private SecurityContextHolder() {}
|
||||
|
||||
/** Returns the context for the current thread, creating an empty one if none is set. */
|
||||
public static SecurityContext getContext() {
|
||||
SecurityContext context = CONTEXT_HOLDER.get();
|
||||
if (context == null) {
|
||||
context = createEmptyContext();
|
||||
CONTEXT_HOLDER.set(context);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
public static void setContext(SecurityContext context) {
|
||||
CONTEXT_HOLDER.set(context);
|
||||
}
|
||||
|
||||
public static void clearContext() {
|
||||
CONTEXT_HOLDER.remove();
|
||||
}
|
||||
|
||||
public static SecurityContext createEmptyContext() {
|
||||
return new SecurityContextImpl();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.core.context.SecurityContextImpl}.
|
||||
*
|
||||
* <p>Basic concrete implementation of {@link SecurityContext}.
|
||||
*/
|
||||
public class SecurityContextImpl implements SecurityContext {
|
||||
|
||||
private Authentication authentication;
|
||||
|
||||
public SecurityContextImpl() {}
|
||||
|
||||
public SecurityContextImpl(Authentication authentication) {
|
||||
this.authentication = authentication;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Authentication getAuthentication() {
|
||||
return authentication;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAuthentication(Authentication authentication) {
|
||||
this.authentication = authentication;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.core.session.SessionInformation}.
|
||||
*
|
||||
* <p>Represents a record of a session within the application's session registry.
|
||||
*/
|
||||
public class SessionInformation {
|
||||
|
||||
private final Object principal;
|
||||
private final String sessionId;
|
||||
private Date lastRequest;
|
||||
private boolean expired = false;
|
||||
|
||||
public SessionInformation(Object principal, String sessionId, Date lastRequest) {
|
||||
this.principal = principal;
|
||||
this.sessionId = sessionId;
|
||||
this.lastRequest = lastRequest;
|
||||
}
|
||||
|
||||
public Object getPrincipal() {
|
||||
return principal;
|
||||
}
|
||||
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public Date getLastRequest() {
|
||||
return lastRequest;
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
return expired;
|
||||
}
|
||||
|
||||
public void expireNow() {
|
||||
this.expired = true;
|
||||
}
|
||||
|
||||
public void refreshLastRequest() {
|
||||
this.lastRequest = new Date();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.core.session.SessionRegistry}.
|
||||
*
|
||||
* <p>Maintains a registry of currently known principals and their sessions.
|
||||
*/
|
||||
public interface SessionRegistry {
|
||||
|
||||
List<Object> getAllPrincipals();
|
||||
|
||||
List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions);
|
||||
|
||||
SessionInformation getSessionInformation(String sessionId);
|
||||
|
||||
void refreshLastRequest(String sessionId);
|
||||
|
||||
void registerNewSession(String sessionId, Object principal);
|
||||
|
||||
void removeSessionInformation(String sessionId);
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.core.authority.SimpleGrantedAuthority}.
|
||||
*
|
||||
* <p>A basic, immutable {@link GrantedAuthority} backed by a single string.
|
||||
*/
|
||||
public class SimpleGrantedAuthority implements GrantedAuthority {
|
||||
|
||||
private final String authority;
|
||||
|
||||
public SimpleGrantedAuthority(String authority) {
|
||||
this.authority = authority;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAuthority() {
|
||||
return authority;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
if (!(obj instanceof SimpleGrantedAuthority)) {
|
||||
return false;
|
||||
}
|
||||
SimpleGrantedAuthority other = (SimpleGrantedAuthority) obj;
|
||||
return Objects.equals(authority, other.authority);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(authority);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return authority;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.core.userdetails.UserDetails}.
|
||||
*
|
||||
* <p>Provides core user information used by the authentication layer.
|
||||
*/
|
||||
public interface UserDetails {
|
||||
|
||||
Collection<? extends GrantedAuthority> getAuthorities();
|
||||
|
||||
String getPassword();
|
||||
|
||||
String getUsername();
|
||||
|
||||
boolean isAccountNonExpired();
|
||||
|
||||
boolean isAccountNonLocked();
|
||||
|
||||
boolean isCredentialsNonExpired();
|
||||
|
||||
boolean isEnabled();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.core.userdetails.UserDetailsService}.
|
||||
*
|
||||
* <p>Loads user-specific data, typically as part of an authentication flow.
|
||||
*/
|
||||
public interface UserDetailsService {
|
||||
|
||||
/**
|
||||
* Locates the user based on the username.
|
||||
*
|
||||
* @param username the username identifying the user whose data is required
|
||||
* @return a fully populated user record, never {@code null}
|
||||
* @throws UsernameNotFoundException if the user could not be found
|
||||
*/
|
||||
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.core.userdetails.UsernameNotFoundException}.
|
||||
*
|
||||
* <p>Thrown if a {@link UserDetailsService} implementation cannot locate a user by its username.
|
||||
*/
|
||||
public class UsernameNotFoundException extends AuthenticationException {
|
||||
|
||||
public UsernameNotFoundException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
|
||||
public UsernameNotFoundException(String msg, Throwable cause) {
|
||||
super(msg, cause);
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package stirling.software.common.security;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* Migration compatibility shim for {@code
|
||||
* org.springframework.security.authentication.UsernamePasswordAuthenticationToken}.
|
||||
*
|
||||
* <p>An {@link Authentication} implementation designed for simple presentation of a username and
|
||||
* password.
|
||||
*/
|
||||
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
private final Object principal;
|
||||
private Object credentials;
|
||||
|
||||
/** Creates an unauthenticated token (typically used as an authentication request). */
|
||||
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
|
||||
super(null);
|
||||
this.principal = principal;
|
||||
this.credentials = credentials;
|
||||
setAuthenticated(false);
|
||||
}
|
||||
|
||||
/** Creates an authenticated token (typically the result of a successful authentication). */
|
||||
public UsernamePasswordAuthenticationToken(
|
||||
Object principal,
|
||||
Object credentials,
|
||||
Collection<? extends GrantedAuthority> authorities) {
|
||||
super(authorities);
|
||||
this.principal = principal;
|
||||
this.credentials = credentials;
|
||||
super.setAuthenticated(true);
|
||||
}
|
||||
|
||||
/** Factory method mirroring Spring Security 6 for creating an unauthenticated token. */
|
||||
public static UsernamePasswordAuthenticationToken unauthenticated(
|
||||
Object principal, Object credentials) {
|
||||
return new UsernamePasswordAuthenticationToken(principal, credentials);
|
||||
}
|
||||
|
||||
/** Factory method mirroring Spring Security 6 for creating an authenticated token. */
|
||||
public static UsernamePasswordAuthenticationToken authenticated(
|
||||
Object principal,
|
||||
Object credentials,
|
||||
Collection<? extends GrantedAuthority> authorities) {
|
||||
return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return principal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
if (principal instanceof UserDetails) {
|
||||
return ((UserDetails) principal).getUsername();
|
||||
}
|
||||
return principal == null ? null : principal.toString();
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -24,17 +24,18 @@ import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
|
||||
import org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction;
|
||||
import org.apache.pdfbox.io.ScratchFile;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@Component
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class CustomPDFDocumentFactory {
|
||||
|
||||
@@ -44,8 +45,8 @@ public class CustomPDFDocumentFactory {
|
||||
// class without a full Spring context. When null, falls back to Files.createTempFile().
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
/** Primary constructor used by Spring. Both collaborators are required in production. */
|
||||
@Autowired
|
||||
/** Primary constructor used by CDI. Both collaborators are required in production. */
|
||||
@Inject
|
||||
public CustomPDFDocumentFactory(
|
||||
PdfMetadataService pdfMetadataService, TempFileManager tempFileManager) {
|
||||
this.pdfMetadataService = pdfMetadataService;
|
||||
|
||||
@@ -4,18 +4,20 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Service
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
|
||||
@ApplicationScoped
|
||||
@RequiredArgsConstructor
|
||||
public class FileOrUploadService {
|
||||
|
||||
@Value("${stirling.tempDir:/tmp/stirling-files}")
|
||||
private String tempDirPath;
|
||||
@ConfigProperty(name = "stirling.tempDir", defaultValue = "/tmp/stirling-files")
|
||||
String tempDirPath;
|
||||
|
||||
public Path resolveFilePath(String fileId) {
|
||||
return Path.of(tempDirPath).resolve(fileId);
|
||||
|
||||
@@ -9,22 +9,23 @@ import java.util.Optional;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Instance;
|
||||
import jakarta.ws.rs.core.StreamingOutput;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.FileStore;
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
import stirling.software.common.model.io.Resource;
|
||||
import stirling.software.common.util.JobContext;
|
||||
|
||||
/**
|
||||
* Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
|
||||
* system to handle file references. Disk I/O is delegated to the injected {@link FileStore} bean.
|
||||
*/
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FileStorage {
|
||||
@@ -34,7 +35,12 @@ public class FileStorage {
|
||||
|
||||
private final FileOrUploadService fileOrUploadService;
|
||||
private final FileStore fileStore;
|
||||
private final Optional<JobOwnershipService> jobOwnershipService;
|
||||
|
||||
// MIGRATION: CDI does not inject java.util.Optional<T>. Optional<JobOwnershipService> is now
|
||||
// jakarta.enterprise.inject.Instance<JobOwnershipService>, resolved via isResolvable()/get().
|
||||
// Exactly one JobOwnershipService impl is selected at build time (Impl vs NoOp), so this is
|
||||
// always resolvable in practice, but Instance<> keeps the previous optional contract.
|
||||
private final Instance<JobOwnershipService> jobOwnershipService;
|
||||
|
||||
public String storeFile(MultipartFile file) throws IOException {
|
||||
String owner = resolveOwner();
|
||||
@@ -97,7 +103,7 @@ public class FileStorage {
|
||||
return new StoredFile(stored.fileId(), stored.size());
|
||||
}
|
||||
|
||||
public String storeFromStreamingBody(StreamingResponseBody body, String originalName)
|
||||
public String storeFromStreamingBody(StreamingOutput body, String originalName)
|
||||
throws IOException {
|
||||
String owner = resolveOwner();
|
||||
// Hold Throwable not IOException: an unchecked failure (NPE, IllegalState, OOM, etc.)
|
||||
@@ -113,7 +119,7 @@ public class FileStorage {
|
||||
executor.submit(
|
||||
() -> {
|
||||
try {
|
||||
body.writeTo(out);
|
||||
body.write(out);
|
||||
} catch (Throwable ex) {
|
||||
bodyError.set(ex);
|
||||
} finally {
|
||||
@@ -142,10 +148,9 @@ public class FileStorage {
|
||||
throw ioe;
|
||||
}
|
||||
throw new IOException(
|
||||
"StreamingResponseBody writer failed: " + writerErr.getMessage(),
|
||||
writerErr);
|
||||
"StreamingOutput writer failed: " + writerErr.getMessage(), writerErr);
|
||||
}
|
||||
log.debug("Stored StreamingResponseBody with ID: {}", stored.fileId());
|
||||
log.debug("Stored StreamingOutput with ID: {}", stored.fileId());
|
||||
return stored.fileId();
|
||||
} finally {
|
||||
// Interrupt and join the writer task: shutdown() alone returns immediately and a
|
||||
@@ -194,11 +199,14 @@ public class FileStorage {
|
||||
if (propagated != null) {
|
||||
return propagated;
|
||||
}
|
||||
return jobOwnershipService.flatMap(JobOwnershipService::getCurrentUserId).orElse(null);
|
||||
if (!jobOwnershipService.isResolvable()) {
|
||||
return null;
|
||||
}
|
||||
return jobOwnershipService.get().getCurrentUserId().orElse(null);
|
||||
}
|
||||
|
||||
private void enforceOwnership(String fileId) {
|
||||
if (jobOwnershipService.isEmpty()) {
|
||||
if (!jobOwnershipService.isResolvable()) {
|
||||
return;
|
||||
}
|
||||
Optional<String> currentUser = jobOwnershipService.get().getCurrentUserId();
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.ConnectException;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RequestCallback;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.eclipse.microprofile.config.Config;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Instance;
|
||||
import jakarta.servlet.ServletContext;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.model.io.FileSystemResource;
|
||||
import stirling.software.common.model.io.Resource;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@@ -33,14 +38,21 @@ import stirling.software.common.util.TempFileManager;
|
||||
* Dispatches HTTP POST requests to internal Stirling API endpoints via loopback. Used by
|
||||
* PipelineProcessor and AiWorkflowService to execute tool operations programmatically without
|
||||
* leaving the JVM network stack.
|
||||
*
|
||||
* <p>MIGRATION (Spring -> Quarkus): the HTTP dispatch was rebuilt on {@link
|
||||
* java.net.http.HttpClient} (replacing Spring's {@code RestTemplate}/{@code
|
||||
* SimpleClientHttpRequestFactory}). The multipart body is encoded manually; {@code
|
||||
* MultiValueMap<String,Object>} became {@code Map<String,List<Object>>} and {@code
|
||||
* ResponseEntity<Resource>} became {@link Response}. {@code ResourceAccessException} timeout
|
||||
* handling is now driven by {@link HttpTimeoutException}.
|
||||
*/
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class InternalApiClient {
|
||||
|
||||
// Allowlist for internal dispatch. Matches fixed namespace prefixes,
|
||||
// but rejects traversal (..), URL-encoding (%), query/fragment, backslashes, and any other
|
||||
// character that could alter the resolved endpoint on the local Spring server.
|
||||
// character that could alter the resolved endpoint on the local server.
|
||||
//
|
||||
// The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are
|
||||
// dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is
|
||||
@@ -63,30 +75,30 @@ public class InternalApiClient {
|
||||
private final ServletContext servletContext;
|
||||
private final UserServiceInterface userService;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final Environment environment;
|
||||
private final Config config;
|
||||
private final Duration readTimeout;
|
||||
private final RestTemplate restTemplate;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public InternalApiClient(
|
||||
ServletContext servletContext,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
Instance<UserServiceInterface> userService,
|
||||
TempFileManager tempFileManager,
|
||||
Environment environment,
|
||||
Config config,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.servletContext = servletContext;
|
||||
this.userService = userService;
|
||||
this.userService = userService.isResolvable() ? userService.get() : null;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.environment = environment;
|
||||
this.config = config;
|
||||
ApplicationProperties.InternalApi internalApi = applicationProperties.getInternalApi();
|
||||
// A bounded read timeout is what protects the workflow when an internal tool hangs
|
||||
// (e.g. an infinite loop in a PDF processing service). The connect timeout is short
|
||||
// because this is a loopback call; if connecting takes longer than a few seconds the
|
||||
// local server is itself unhealthy.
|
||||
this.readTimeout = Duration.ofSeconds(internalApi.getReadTimeoutSeconds());
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(Duration.ofSeconds(internalApi.getConnectTimeoutSeconds()));
|
||||
factory.setReadTimeout(readTimeout);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
this.httpClient =
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(internalApi.getConnectTimeoutSeconds()))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,78 +106,110 @@ public class InternalApiClient {
|
||||
* prefixes (e.g. {@code /api/v1/misc/compress-pdf}).
|
||||
*
|
||||
* @param endpointPath API path (e.g. {@code /api/v1/general/rotate-pdf})
|
||||
* @param body multipart form body (fileInput + parameters)
|
||||
* @return response with the result file as a {@link TempFileResource} body
|
||||
* @param body multipart form body (fileInput + parameters): each value is either a {@link
|
||||
* Resource} (file part) or a {@code String} (form field)
|
||||
* @return JAX-RS {@link Response} with the result file as a {@link TempFileResource} entity
|
||||
*/
|
||||
public ResponseEntity<Resource> post(String endpointPath, MultiValueMap<String, Object> body) {
|
||||
public Response post(String endpointPath, Map<String, List<Object>> body) {
|
||||
validateUrl(endpointPath);
|
||||
String url = getBaseUrl() + endpointPath;
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
String boundary = "----StirlingBoundary" + Long.toHexString(System.nanoTime());
|
||||
byte[] multipartBody = encodeMultipart(body, boundary);
|
||||
|
||||
HttpRequest.Builder requestBuilder =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(readTimeout)
|
||||
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(multipartBody));
|
||||
|
||||
String apiKey = getApiKeyForUser();
|
||||
if (apiKey != null && !apiKey.isEmpty()) {
|
||||
headers.add("X-API-KEY", apiKey);
|
||||
requestBuilder.header("X-API-KEY", apiKey);
|
||||
}
|
||||
// Tag the sub-step as automation so PAYG bills it under AUTOMATION regardless of which
|
||||
// tool-level @RequiresFeature annotation the dispatched controller carries (e.g. an AI-OCR
|
||||
// step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because
|
||||
// every caller of this dispatcher is an automation surface by design.
|
||||
headers.add(AUTOMATION_HEADER, "true");
|
||||
|
||||
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
|
||||
// without this RestTemplate would use urlencoded instead of the multipart the controller
|
||||
// expects. File-bearing calls get the right multipart content-type from RestTemplate.
|
||||
boolean isAiTool = endpointPath.startsWith("/api/v1/ai/tools/");
|
||||
boolean hasFilePart =
|
||||
body.values().stream()
|
||||
.flatMap(java.util.List::stream)
|
||||
.anyMatch(v -> v instanceof Resource);
|
||||
if (isAiTool && !hasFilePart) {
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
}
|
||||
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
|
||||
requestBuilder.header(AUTOMATION_HEADER, "true");
|
||||
|
||||
try {
|
||||
return restTemplate.execute(
|
||||
url,
|
||||
HttpMethod.POST,
|
||||
requestCallback,
|
||||
response -> {
|
||||
try {
|
||||
TempFile tempFile =
|
||||
tempFileManager.createManagedTempFile("internal-api");
|
||||
Files.copy(
|
||||
response.getBody(),
|
||||
tempFile.getPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
String filename = extractFilename(response.getHeaders());
|
||||
TempFileResource resource = new TempFileResource(tempFile, filename);
|
||||
return ResponseEntity.status(response.getStatusCode())
|
||||
.headers(response.getHeaders())
|
||||
.body(resource);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
} catch (ResourceAccessException e) {
|
||||
// RestTemplate wraps low-level I/O failures in ResourceAccessException. Only the
|
||||
// SocketTimeoutException-rooted case is a real timeout; other I/O failures (connection
|
||||
// refused, DNS, etc.) propagate as-is so the upstream generic handler can describe
|
||||
// them accurately.
|
||||
if (e.getCause() instanceof java.net.SocketTimeoutException) {
|
||||
throw new InternalApiTimeoutException(endpointPath, readTimeout, e);
|
||||
HttpResponse<InputStream> response =
|
||||
httpClient.send(
|
||||
requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
|
||||
try (InputStream responseBody = response.body()) {
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("internal-api");
|
||||
Files.copy(
|
||||
responseBody,
|
||||
tempFile.getPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
String filename =
|
||||
extractFilename(
|
||||
response.headers().firstValue("Content-Disposition").orElse(null));
|
||||
TempFileResource resource = new TempFileResource(tempFile, filename);
|
||||
Response.ResponseBuilder rb =
|
||||
Response.status(response.statusCode()).entity(resource);
|
||||
response.headers().map().forEach((k, vs) -> vs.forEach(v -> rb.header(k, v)));
|
||||
return rb.build();
|
||||
}
|
||||
throw e;
|
||||
} catch (HttpTimeoutException e) {
|
||||
throw new InternalApiTimeoutException(endpointPath, readTimeout, e);
|
||||
} catch (ConnectException e) {
|
||||
throw new UncheckedIOException(new IOException("Internal API connection failed", e));
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("Internal API dispatch interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the filename from a response's {@code Content-Disposition} header. Returns {@code
|
||||
* null} if the header is missing or has no filename.
|
||||
* Encode a multipart/form-data body. File parts are {@link Resource}; others are form fields.
|
||||
*/
|
||||
private static String extractFilename(HttpHeaders headers) {
|
||||
String contentDisposition = headers.getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
||||
private static byte[] encodeMultipart(Map<String, List<Object>> body, String boundary) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try {
|
||||
for (Map.Entry<String, List<Object>> entry : body.entrySet()) {
|
||||
String name = entry.getKey();
|
||||
for (Object value : entry.getValue()) {
|
||||
baos.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
if (value instanceof Resource resource) {
|
||||
String fn = resource.getFilename() != null ? resource.getFilename() : name;
|
||||
baos.write(
|
||||
("Content-Disposition: form-data; name=\""
|
||||
+ name
|
||||
+ "\"; filename=\""
|
||||
+ fn
|
||||
+ "\"\r\n"
|
||||
+ "Content-Type: application/octet-stream\r\n\r\n")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
} else {
|
||||
baos.write(
|
||||
("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
baos.write(String.valueOf(value).getBytes(StandardCharsets.UTF_8));
|
||||
baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
baos.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the filename from a {@code Content-Disposition} header value. Returns {@code null} if
|
||||
* the header is missing or has no filename.
|
||||
*/
|
||||
private static String extractFilename(String contentDisposition) {
|
||||
if (contentDisposition == null || contentDisposition.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
@@ -184,12 +228,13 @@ public class InternalApiClient {
|
||||
}
|
||||
|
||||
private String getBaseUrl() {
|
||||
// Resolve the port lazily so desktop mode (server.port=0, OS-assigned) dispatches to the
|
||||
// actual bound port. Spring publishes local.server.port once the web server is up; fall
|
||||
// back to the configured server.port for early calls (tests, non-web contexts).
|
||||
String port = environment.getProperty("local.server.port");
|
||||
// Resolve the port lazily so desktop mode dispatches to the actual bound port.
|
||||
// TODO: Migration required - verify Quarkus exposes the bound port via config. Quarkus uses
|
||||
// "quarkus.http.port" and, for random-port test/dev runs, "quarkus.http.test-port"; the old
|
||||
// "local.server.port"/"server.port" keys came from Spring Boot's WebServerInitializedEvent.
|
||||
String port = config.getOptionalValue("quarkus.http.port", String.class).orElse(null);
|
||||
if (port == null) {
|
||||
port = environment.getProperty("server.port", "8080");
|
||||
port = config.getOptionalValue("server.port", String.class).orElse("8080");
|
||||
}
|
||||
return "http://localhost:" + port + servletContext.getContextPath();
|
||||
}
|
||||
|
||||
@@ -9,51 +9,56 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Instance;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.StreamingOutput;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
import stirling.software.common.model.io.Resource;
|
||||
import stirling.software.common.model.job.JobResponse;
|
||||
import stirling.software.common.util.ExecutorFactory;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
/** Service for executing jobs asynchronously or synchronously */
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class JobExecutorService {
|
||||
|
||||
private static final String APPLICATION_PDF_VALUE = "application/pdf";
|
||||
|
||||
private final TaskManager taskManager;
|
||||
private final FileStorage fileStorage;
|
||||
private final HttpServletRequest request;
|
||||
// Reactive-safe: the undertow HttpServletRequest proxy throws UT000048 on RESTEasy Reactive
|
||||
// worker threads, so the per-request "jobId" attribute is stored on the Vert.x RoutingContext
|
||||
// instead (read back via AutoJobAspect). Off a live request this degrades to a no-op.
|
||||
private final io.quarkus.vertx.http.runtime.CurrentVertxRequest currentVertxRequest;
|
||||
private final ResourceMonitor resourceMonitor;
|
||||
private final JobQueue jobQueue;
|
||||
private final ExecutorService executor = ExecutorFactory.newVirtualThreadExecutor();
|
||||
private final long effectiveTimeoutMs;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JobOwnershipService jobOwnershipService;
|
||||
@jakarta.inject.Inject Instance<JobOwnershipService> jobOwnershipService;
|
||||
|
||||
public JobExecutorService(
|
||||
TaskManager taskManager,
|
||||
FileStorage fileStorage,
|
||||
HttpServletRequest request,
|
||||
io.quarkus.vertx.http.runtime.CurrentVertxRequest currentVertxRequest,
|
||||
ResourceMonitor resourceMonitor,
|
||||
JobQueue jobQueue,
|
||||
@Value("${spring.mvc.async.request-timeout:1200000}") long asyncRequestTimeoutMs,
|
||||
@Value("${server.servlet.session.timeout:30m}") String sessionTimeout) {
|
||||
@ConfigProperty(name = "spring.mvc.async.request-timeout", defaultValue = "1200000")
|
||||
long asyncRequestTimeoutMs,
|
||||
@ConfigProperty(name = "server.servlet.session.timeout", defaultValue = "30m")
|
||||
String sessionTimeout) {
|
||||
this.taskManager = taskManager;
|
||||
this.fileStorage = fileStorage;
|
||||
this.request = request;
|
||||
this.currentVertxRequest = currentVertxRequest;
|
||||
this.resourceMonitor = resourceMonitor;
|
||||
this.jobQueue = jobQueue;
|
||||
|
||||
@@ -63,16 +68,15 @@ public class JobExecutorService {
|
||||
"Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs);
|
||||
}
|
||||
|
||||
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
|
||||
public Response runJobGeneric(boolean async, Supplier<Object> work) {
|
||||
return runJobGeneric(async, work, -1);
|
||||
}
|
||||
|
||||
public ResponseEntity<?> runJobGeneric(
|
||||
boolean async, Supplier<Object> work, long customTimeoutMs) {
|
||||
public Response runJobGeneric(boolean async, Supplier<Object> work, long customTimeoutMs) {
|
||||
return runJobGeneric(async, work, customTimeoutMs, false, 50);
|
||||
}
|
||||
|
||||
public ResponseEntity<?> runJobGeneric(
|
||||
public Response runJobGeneric(
|
||||
boolean async,
|
||||
Supplier<Object> work,
|
||||
long customTimeoutMs,
|
||||
@@ -83,15 +87,20 @@ public class JobExecutorService {
|
||||
|
||||
log.debug("Generated jobId: {} (base: {})", scopedJobKey, baseJobId);
|
||||
|
||||
if (request != null) {
|
||||
request.setAttribute("jobId", scopedJobKey);
|
||||
try {
|
||||
var current = currentVertxRequest.getCurrent();
|
||||
if (current != null) {
|
||||
current.put("jobId", scopedJobKey);
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
// No active request (e.g. async/background execution) - jobId attribute is optional.
|
||||
}
|
||||
|
||||
String jobId = scopedJobKey;
|
||||
|
||||
final String jobOwner =
|
||||
jobOwnershipService != null
|
||||
? jobOwnershipService.getCurrentUserId().orElse(null)
|
||||
jobOwnershipService.isResolvable()
|
||||
? jobOwnershipService.get().getCurrentUserId().orElse(null)
|
||||
: null;
|
||||
|
||||
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
|
||||
@@ -141,10 +150,10 @@ public class JobExecutorService {
|
||||
}
|
||||
};
|
||||
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
CompletableFuture<Response> future =
|
||||
jobQueue.queueJob(jobId, resourceWeight, wrappedWork, timeoutToUse);
|
||||
|
||||
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
|
||||
return Response.ok(new JobResponse<>(true, jobId, null)).build();
|
||||
} else if (async) {
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
@@ -173,7 +182,7 @@ public class JobExecutorService {
|
||||
}
|
||||
});
|
||||
|
||||
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
|
||||
return Response.ok(new JobResponse<>(true, jobId, null)).build();
|
||||
} else {
|
||||
try {
|
||||
log.debug("Running sync job with timeout {} ms", timeoutToUse);
|
||||
@@ -181,15 +190,16 @@ public class JobExecutorService {
|
||||
stirling.software.common.util.JobContext.setJobId(jobId);
|
||||
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
|
||||
|
||||
if (result instanceof ResponseEntity) {
|
||||
return (ResponseEntity<?>) result;
|
||||
if (result instanceof Response) {
|
||||
return (Response) result;
|
||||
}
|
||||
|
||||
return handleResultForSyncJob(result);
|
||||
} catch (TimeoutException te) {
|
||||
log.error("Synchronous job timed out after {} ms", timeoutToUse);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Job timed out after " + timeoutToUse + " ms"));
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", "Job timed out after " + timeoutToUse + " ms"))
|
||||
.build();
|
||||
} catch (RuntimeException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (e instanceof IllegalArgumentException
|
||||
@@ -203,12 +213,14 @@ public class JobExecutorService {
|
||||
throw e;
|
||||
}
|
||||
log.error("Error executing synchronous job: {}", e.getMessage(), e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Job failed: " + e.getMessage()));
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", "Job failed: " + e.getMessage()))
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
log.error("Error executing synchronous job: {}", e.getMessage(), e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Job failed: " + e.getMessage()));
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", "Job failed: " + e.getMessage()))
|
||||
.build();
|
||||
} finally {
|
||||
stirling.software.common.util.JobContext.clear();
|
||||
}
|
||||
@@ -219,12 +231,11 @@ public class JobExecutorService {
|
||||
try {
|
||||
if (result instanceof byte[]) {
|
||||
String fileId = fileStorage.storeBytes((byte[]) result, "result.pdf");
|
||||
taskManager.setFileResult(
|
||||
jobId, fileId, "result.pdf", MediaType.APPLICATION_PDF_VALUE);
|
||||
taskManager.setFileResult(jobId, fileId, "result.pdf", APPLICATION_PDF_VALUE);
|
||||
log.debug("Stored byte[] result with fileId: {}", fileId);
|
||||
} else if (result instanceof ResponseEntity) {
|
||||
ResponseEntity<?> response = (ResponseEntity<?>) result;
|
||||
Object body = response.getBody();
|
||||
} else if (result instanceof Response) {
|
||||
Response response = (Response) result;
|
||||
Object body = response.getEntity();
|
||||
|
||||
if (body instanceof byte[]) {
|
||||
String filename = extractResponseFilename(response);
|
||||
@@ -232,23 +243,23 @@ public class JobExecutorService {
|
||||
|
||||
String fileId = fileStorage.storeBytes((byte[]) body, filename);
|
||||
taskManager.setFileResult(jobId, fileId, filename, contentType);
|
||||
log.debug("Stored ResponseEntity<byte[]> result with fileId: {}", fileId);
|
||||
} else if (body instanceof StreamingResponseBody streamingBody) {
|
||||
log.debug("Stored Response<byte[]> result with fileId: {}", fileId);
|
||||
} else if (body instanceof StreamingOutput streamingBody) {
|
||||
// JAX-RS Response carries a StreamingOutput for streamed bodies (migrated from
|
||||
// Spring's StreamingResponseBody).
|
||||
String filename = extractResponseFilename(response);
|
||||
String contentType = extractResponseContentType(response);
|
||||
|
||||
String fileId = fileStorage.storeFromStreamingBody(streamingBody, filename);
|
||||
taskManager.setFileResult(jobId, fileId, filename, contentType);
|
||||
log.debug(
|
||||
"Stored ResponseEntity<StreamingResponseBody> result with fileId: {}",
|
||||
fileId);
|
||||
log.debug("Stored Response<StreamingOutput> result with fileId: {}", fileId);
|
||||
} else if (body instanceof Resource resource) {
|
||||
String filename = extractResponseFilename(response);
|
||||
String contentType = extractResponseContentType(response);
|
||||
|
||||
String fileId = fileStorage.storeFromResource(resource, filename);
|
||||
taskManager.setFileResult(jobId, fileId, filename, contentType);
|
||||
log.debug("Stored ResponseEntity<Resource> result with fileId: {}", fileId);
|
||||
log.debug("Stored Response<Resource> result with fileId: {}", fileId);
|
||||
} else {
|
||||
if (body != null && body.toString().contains("fileId")) {
|
||||
try {
|
||||
@@ -258,7 +269,7 @@ public class JobExecutorService {
|
||||
|
||||
if (fileId != null && !fileId.isEmpty()) {
|
||||
String filename = "result.pdf";
|
||||
String contentType = MediaType.APPLICATION_PDF_VALUE;
|
||||
String contentType = APPLICATION_PDF_VALUE;
|
||||
|
||||
try {
|
||||
java.lang.reflect.Method getOriginalFileName =
|
||||
@@ -312,7 +323,7 @@ public class JobExecutorService {
|
||||
|
||||
if (fileId != null && !fileId.isEmpty()) {
|
||||
String filename = "result.pdf";
|
||||
String contentType = MediaType.APPLICATION_PDF_VALUE;
|
||||
String contentType = APPLICATION_PDF_VALUE;
|
||||
|
||||
try {
|
||||
java.lang.reflect.Method getOriginalFileName =
|
||||
@@ -358,31 +369,35 @@ public class JobExecutorService {
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<?> handleResultForSyncJob(Object result) throws IOException {
|
||||
private Response handleResultForSyncJob(Object result) throws IOException {
|
||||
if (result instanceof byte[]) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
return Response.ok(result)
|
||||
.type(MediaType.valueOf(APPLICATION_PDF_VALUE))
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"form-data; name=\"attachment\"; filename=\"result.pdf\"")
|
||||
.body(result);
|
||||
.build();
|
||||
} else if (result instanceof MultipartFile file) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(file.getContentType()))
|
||||
return Response.ok(file.getBytes())
|
||||
.type(MediaType.valueOf(file.getContentType()))
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"form-data; name=\"attachment\"; filename=\""
|
||||
+ file.getOriginalFilename()
|
||||
+ "\"")
|
||||
.body(file.getBytes());
|
||||
.build();
|
||||
} else {
|
||||
return ResponseEntity.ok(result);
|
||||
return Response.ok(result).build();
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractResponseFilename(ResponseEntity<?> response) {
|
||||
if (response.getHeaders().getContentDisposition() != null) {
|
||||
String filename = response.getHeaders().getContentDisposition().getFilename();
|
||||
private static String extractResponseFilename(Response response) {
|
||||
// JAX-RS exposes Content-Disposition as a raw header string; parse the filename token out
|
||||
// of
|
||||
// it (Spring previously used ContentDisposition#getFilename()).
|
||||
String contentDisposition = response.getHeaderString(HttpHeaders.CONTENT_DISPOSITION);
|
||||
if (contentDisposition != null) {
|
||||
String filename = parseFilenameFromContentDisposition(contentDisposition);
|
||||
if (filename != null && !filename.isEmpty()) {
|
||||
return filename;
|
||||
}
|
||||
@@ -390,9 +405,23 @@ public class JobExecutorService {
|
||||
return "result.pdf";
|
||||
}
|
||||
|
||||
private static String extractResponseContentType(ResponseEntity<?> response) {
|
||||
MediaType mediaType = response.getHeaders().getContentType();
|
||||
return mediaType != null ? mediaType.toString() : MediaType.APPLICATION_PDF_VALUE;
|
||||
private static String parseFilenameFromContentDisposition(String contentDisposition) {
|
||||
for (String part : contentDisposition.split(";")) {
|
||||
String trimmed = part.trim();
|
||||
if (trimmed.regionMatches(true, 0, "filename=", 0, "filename=".length())) {
|
||||
String value = trimmed.substring("filename=".length()).trim();
|
||||
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
|
||||
value = value.substring(1, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String extractResponseContentType(Response response) {
|
||||
MediaType mediaType = response.getMediaType();
|
||||
return mediaType != null ? mediaType.toString() : APPLICATION_PDF_VALUE;
|
||||
}
|
||||
|
||||
private long parseSessionTimeout(String timeout) {
|
||||
@@ -463,8 +492,8 @@ public class JobExecutorService {
|
||||
}
|
||||
|
||||
private String getScopedJobKey(String baseJobId) {
|
||||
if (jobOwnershipService != null) {
|
||||
return jobOwnershipService.createScopedJobKey(baseJobId);
|
||||
if (jobOwnershipService.isResolvable()) {
|
||||
return jobOwnershipService.get().createScopedJobKey(baseJobId);
|
||||
}
|
||||
return baseJobId;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@ import java.util.Map;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import io.quarkus.runtime.StartupEvent;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.event.Observes;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
@@ -22,25 +26,33 @@ import stirling.software.common.util.SpringContextHolder;
|
||||
* Manages a queue of jobs with dynamic sizing based on system resources. Used when system resources
|
||||
* are limited to prevent overloading.
|
||||
*/
|
||||
@Service
|
||||
// TODO: Migration required - the original class implemented Spring's SmartLifecycle, which has no
|
||||
// direct Quarkus equivalent. start() is now driven by a StartupEvent observer and stop() by
|
||||
// @PreDestroy. The SmartLifecycle phase/auto-startup ordering semantics (getPhase()==10) cannot be
|
||||
// expressed in CDI; if precise startup/shutdown ordering relative to other beans is required,
|
||||
// revisit using @Priority on the observer or @io.quarkus.runtime.Startup with an ordering strategy.
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class JobQueue implements SmartLifecycle {
|
||||
public class JobQueue {
|
||||
|
||||
private volatile boolean running = false;
|
||||
|
||||
private final ResourceMonitor resourceMonitor;
|
||||
|
||||
@Value("${stirling.job.queue.base-capacity:10}")
|
||||
private int baseQueueCapacity = 10;
|
||||
// Field-default values mirror the @ConfigProperty defaults so they hold sane values during
|
||||
// construction (the configured values are injected by CDI only after the constructor runs, and
|
||||
// the constructor below sizes the queue from baseQueueCapacity/minQueueCapacity).
|
||||
@ConfigProperty(name = "stirling.job.queue.base-capacity", defaultValue = "10")
|
||||
int baseQueueCapacity = 10;
|
||||
|
||||
@Value("${stirling.job.queue.min-capacity:2}")
|
||||
private int minQueueCapacity = 2;
|
||||
@ConfigProperty(name = "stirling.job.queue.min-capacity", defaultValue = "2")
|
||||
int minQueueCapacity = 2;
|
||||
|
||||
@Value("${stirling.job.queue.check-interval-ms:1000}")
|
||||
private long queueCheckIntervalMs = 1000;
|
||||
@ConfigProperty(name = "stirling.job.queue.check-interval-ms", defaultValue = "1000")
|
||||
long queueCheckIntervalMs = 1000;
|
||||
|
||||
@Value("${stirling.job.queue.max-wait-time-ms:600000}")
|
||||
private long maxWaitTimeMs = 600000; // 10 minutes
|
||||
@ConfigProperty(name = "stirling.job.queue.max-wait-time-ms", defaultValue = "600000")
|
||||
long maxWaitTimeMs = 600000; // 10 minutes
|
||||
|
||||
private volatile BlockingQueue<QueuedJob> jobQueue;
|
||||
private final Map<String, QueuedJob> jobMap = new ConcurrentHashMap<>();
|
||||
@@ -67,20 +79,23 @@ public class JobQueue implements SmartLifecycle {
|
||||
private final Supplier<Object> work;
|
||||
private final long timeoutMs;
|
||||
private final Instant queuedAt;
|
||||
private CompletableFuture<ResponseEntity<?>> future;
|
||||
private CompletableFuture<Response> future;
|
||||
private volatile boolean cancelled = false;
|
||||
}
|
||||
|
||||
public JobQueue(ResourceMonitor resourceMonitor) {
|
||||
this.resourceMonitor = resourceMonitor;
|
||||
|
||||
// Initialize with dynamic capacity
|
||||
// Initialize the queue with a dynamic capacity in the constructor (not in
|
||||
// initializeSchedulers) so it is usable immediately after construction, before the
|
||||
// StartupEvent observer starts the schedulers. Uses the field-default capacities since the
|
||||
// configured values are injected only after construction; updateQueueCapacity() re-sizes
|
||||
// once the configured values are available.
|
||||
int capacity =
|
||||
resourceMonitor.calculateDynamicQueueCapacity(baseQueueCapacity, minQueueCapacity);
|
||||
this.jobQueue = new LinkedBlockingQueue<>(capacity);
|
||||
}
|
||||
|
||||
// Remove @PostConstruct to let SmartLifecycle control startup
|
||||
private void initializeSchedulers() {
|
||||
log.debug(
|
||||
"Starting job queue with base capacity {}, min capacity {}",
|
||||
@@ -99,7 +114,6 @@ public class JobQueue implements SmartLifecycle {
|
||||
TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
// Remove @PreDestroy to let SmartLifecycle control shutdown
|
||||
private void shutdownSchedulers() {
|
||||
log.info("Shutting down job queue");
|
||||
shuttingDown = true;
|
||||
@@ -136,9 +150,12 @@ public class JobQueue implements SmartLifecycle {
|
||||
rejectedJobs);
|
||||
}
|
||||
|
||||
// SmartLifecycle methods
|
||||
// Lifecycle methods (migrated from Spring SmartLifecycle)
|
||||
|
||||
void onStart(@Observes StartupEvent event) {
|
||||
start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
log.info("Starting JobQueue lifecycle");
|
||||
if (!running) {
|
||||
@@ -147,29 +164,17 @@ public class JobQueue implements SmartLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
log.info("Stopping JobQueue lifecycle");
|
||||
shutdownSchedulers();
|
||||
running = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPhase() {
|
||||
// Start earlier than most components, but shutdown later
|
||||
return 10;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAutoStartup() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a job for execution when resources permit.
|
||||
*
|
||||
@@ -179,11 +184,11 @@ public class JobQueue implements SmartLifecycle {
|
||||
* @param timeoutMs The timeout in milliseconds
|
||||
* @return A CompletableFuture that will complete when the job is executed
|
||||
*/
|
||||
public CompletableFuture<ResponseEntity<?>> queueJob(
|
||||
public CompletableFuture<Response> queueJob(
|
||||
String jobId, int resourceWeight, Supplier<Object> work, long timeoutMs) {
|
||||
|
||||
// Create a CompletableFuture to track this job's completion
|
||||
CompletableFuture<ResponseEntity<?>> future = new CompletableFuture<>();
|
||||
CompletableFuture<Response> future = new CompletableFuture<>();
|
||||
|
||||
// Create the queued job
|
||||
QueuedJob job =
|
||||
@@ -378,10 +383,10 @@ public class JobQueue implements SmartLifecycle {
|
||||
Object result = executeWithTimeout(job.work, job.timeoutMs);
|
||||
|
||||
// Process the result
|
||||
if (result instanceof ResponseEntity) {
|
||||
job.future.complete((ResponseEntity<?>) result);
|
||||
if (result instanceof Response) {
|
||||
job.future.complete((Response) result);
|
||||
} else {
|
||||
job.future.complete(ResponseEntity.ok(result));
|
||||
job.future.complete(Response.ok(result).build());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -10,17 +10,19 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import io.quarkus.scheduler.Scheduled;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
|
||||
/**
|
||||
* Service for handling mobile scanner file uploads and temporary storage. Files are stored
|
||||
* temporarily and automatically cleaned up after 10 minutes or upon retrieval.
|
||||
*/
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class MobileScannerService {
|
||||
|
||||
@@ -252,7 +254,7 @@ public class MobileScannerService {
|
||||
}
|
||||
|
||||
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
|
||||
@Scheduled(fixedRate = 5 * 60 * 1000)
|
||||
@Scheduled(every = "5m")
|
||||
public void cleanupExpiredSessions() {
|
||||
long now = System.currentTimeMillis();
|
||||
List<String> expiredSessions = new ArrayList<>();
|
||||
|
||||
@@ -9,7 +9,8 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -28,7 +29,7 @@ import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
public class PdfAnnotationService {
|
||||
|
||||
/** Yellow sticky-note fill colour (R, G, B in 0..1 range). */
|
||||
|
||||
@@ -7,26 +7,32 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.Calendar;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Instance;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Named;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.PdfMetadata;
|
||||
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
public class PdfMetadataService {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final String stirlingPDFLabel;
|
||||
private final UserServiceInterface userService;
|
||||
// MIGRATION: Spring's @Autowired(required=false) optional bean -> CDI Instance<>
|
||||
// (UserServiceInterface
|
||||
// is only present in security-enabled flavors). Resolve via isResolvable()/get().
|
||||
private final Instance<UserServiceInterface> userService;
|
||||
private final boolean runningProOrHigher;
|
||||
|
||||
@Inject
|
||||
public PdfMetadataService(
|
||||
ApplicationProperties applicationProperties,
|
||||
@Qualifier("StirlingPDFLabel") String stirlingPDFLabel,
|
||||
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
@Named("StirlingPDFLabel") String stirlingPDFLabel,
|
||||
@Named("runningProOrHigher") boolean runningProOrHigher,
|
||||
Instance<UserServiceInterface> userService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.stirlingPDFLabel = stirlingPDFLabel;
|
||||
this.userService = userService;
|
||||
@@ -168,8 +174,8 @@ public class PdfMetadataService {
|
||||
.getCustomMetadata()
|
||||
.getAuthor();
|
||||
|
||||
if (userService != null) {
|
||||
String username = userService.getCurrentUsername();
|
||||
if (userService.isResolvable()) {
|
||||
String username = userService.get().getCurrentUsername();
|
||||
if (username != null) {
|
||||
author = author.replace("username", username);
|
||||
}
|
||||
|
||||
@@ -15,39 +15,44 @@ import java.util.Map;
|
||||
import java.util.TimeZone;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.eclipse.microprofile.config.Config;
|
||||
|
||||
import com.posthog.java.PostHog;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Instance;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Named;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
public class PostHogService {
|
||||
private final PostHog postHog;
|
||||
private final String uniqueId;
|
||||
private final String appVersion;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final UserServiceInterface userService;
|
||||
private final Environment env;
|
||||
// MIGRATION: optional bean (@Autowired(required=false)) -> CDI Instance<>.
|
||||
private final Instance<UserServiceInterface> userService;
|
||||
// MIGRATION: Spring Environment -> MicroProfile Config.
|
||||
private final Config config;
|
||||
private boolean configDirMounted;
|
||||
|
||||
@Inject
|
||||
public PostHogService(
|
||||
PostHog postHog,
|
||||
@Qualifier("UUID") String uuid,
|
||||
@Qualifier("configDirMounted") boolean configDirMounted,
|
||||
@Qualifier("appVersion") String appVersion,
|
||||
@Named("UUID") String uuid,
|
||||
@Named("configDirMounted") boolean configDirMounted,
|
||||
@Named("appVersion") String appVersion,
|
||||
ApplicationProperties applicationProperties,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
Environment env) {
|
||||
Instance<UserServiceInterface> userService,
|
||||
Config config) {
|
||||
this.postHog = postHog;
|
||||
this.uniqueId = uuid;
|
||||
this.appVersion = appVersion;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.userService = userService;
|
||||
this.env = env;
|
||||
this.config = config;
|
||||
this.configDirMounted = configDirMounted;
|
||||
captureSystemInfo();
|
||||
}
|
||||
@@ -79,7 +84,9 @@ public class PostHogService {
|
||||
// Application version
|
||||
metrics.put("app_version", appVersion);
|
||||
String deploymentType = "JAR"; // default
|
||||
if ("true".equalsIgnoreCase(env.getProperty("BROWSER_OPEN"))) {
|
||||
if ("true"
|
||||
.equalsIgnoreCase(
|
||||
config.getOptionalValue("BROWSER_OPEN", String.class).orElse(null))) {
|
||||
deploymentType = "EXE";
|
||||
} else if (isRunningInDocker()) {
|
||||
deploymentType = "DOCKER";
|
||||
@@ -148,8 +155,8 @@ public class PostHogService {
|
||||
}
|
||||
metrics.put("application_properties", captureApplicationProperties());
|
||||
|
||||
if (userService != null) {
|
||||
metrics.put("total_users_created", userService.getTotalUsersCount());
|
||||
if (userService.isResolvable()) {
|
||||
metrics.put("total_users_created", userService.get().getTotalUsersCount());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -11,11 +11,11 @@ import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -24,24 +24,24 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* Monitors system resources (CPU, memory) to inform job scheduling decisions. Provides information
|
||||
* about available resources to prevent overloading the system.
|
||||
*/
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class ResourceMonitor {
|
||||
|
||||
@Value("${stirling.resource.memory.critical-threshold:0.9}")
|
||||
private double memoryCriticalThreshold = 0.9; // 90% usage is critical
|
||||
@ConfigProperty(name = "stirling.resource.memory.critical-threshold", defaultValue = "0.9")
|
||||
double memoryCriticalThreshold; // 90% usage is critical
|
||||
|
||||
@Value("${stirling.resource.memory.high-threshold:0.75}")
|
||||
private double memoryHighThreshold = 0.75; // 75% usage is high
|
||||
@ConfigProperty(name = "stirling.resource.memory.high-threshold", defaultValue = "0.75")
|
||||
double memoryHighThreshold; // 75% usage is high
|
||||
|
||||
@Value("${stirling.resource.cpu.critical-threshold:0.9}")
|
||||
private double cpuCriticalThreshold = 0.9; // 90% usage is critical
|
||||
@ConfigProperty(name = "stirling.resource.cpu.critical-threshold", defaultValue = "0.9")
|
||||
double cpuCriticalThreshold; // 90% usage is critical
|
||||
|
||||
@Value("${stirling.resource.cpu.high-threshold:0.75}")
|
||||
private double cpuHighThreshold = 0.75; // 75% usage is high
|
||||
@ConfigProperty(name = "stirling.resource.cpu.high-threshold", defaultValue = "0.75")
|
||||
double cpuHighThreshold; // 75% usage is high
|
||||
|
||||
@Value("${stirling.resource.monitor.interval-ms:60000}")
|
||||
private long monitorIntervalMs = 60000; // 60 seconds
|
||||
@ConfigProperty(name = "stirling.resource.monitor.interval-ms", defaultValue = "60000")
|
||||
long monitorIntervalMs; // 60 seconds
|
||||
|
||||
private final ScheduledExecutorService scheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ import java.net.UnknownHostException;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -16,7 +16,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SsrfProtectionService {
|
||||
|
||||
@@ -20,14 +20,13 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import io.github.pixee.security.ZipSecurity;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -40,13 +39,13 @@ import stirling.software.common.model.job.JobStats;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
|
||||
/** Manages async tasks and their results */
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class TaskManager {
|
||||
private final Map<String, JobResult> jobResults = new ConcurrentHashMap<>();
|
||||
|
||||
@Value("${stirling.jobResultExpiryMinutes:30}")
|
||||
private int jobResultExpiryMinutes = 30;
|
||||
@ConfigProperty(name = "stirling.jobResultExpiryMinutes", defaultValue = "30")
|
||||
int jobResultExpiryMinutes;
|
||||
|
||||
private final FileStorage fileStorage;
|
||||
private final JobStore jobStore;
|
||||
@@ -55,7 +54,7 @@ public class TaskManager {
|
||||
Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("task-cleanup-", 0).factory());
|
||||
|
||||
@Autowired
|
||||
@Inject
|
||||
public TaskManager(
|
||||
FileStorage fileStorage, JobStore jobStore, ClusterBackplane clusterBackplane) {
|
||||
this.fileStorage = fileStorage;
|
||||
@@ -475,24 +474,27 @@ public class TaskManager {
|
||||
/** Determine content type based on file extension */
|
||||
private String determineContentType(String fileName) {
|
||||
if (fileName == null) {
|
||||
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
// jakarta.ws.rs.core.MediaType lacks PDF/JPEG/PNG constants and uses no _VALUE
|
||||
// suffix, so the original Spring MediaType.*_VALUE strings are inlined here verbatim
|
||||
// to preserve exact behavior.
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
String lowerName = fileName.toLowerCase(Locale.ROOT);
|
||||
if (lowerName.endsWith(".pdf")) {
|
||||
return MediaType.APPLICATION_PDF_VALUE;
|
||||
return "application/pdf";
|
||||
} else if (lowerName.endsWith(".txt")) {
|
||||
return MediaType.TEXT_PLAIN_VALUE;
|
||||
return "text/plain";
|
||||
} else if (lowerName.endsWith(".json")) {
|
||||
return MediaType.APPLICATION_JSON_VALUE;
|
||||
return "application/json";
|
||||
} else if (lowerName.endsWith(".xml")) {
|
||||
return MediaType.APPLICATION_XML_VALUE;
|
||||
return "application/xml";
|
||||
} else if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) {
|
||||
return MediaType.IMAGE_JPEG_VALUE;
|
||||
return "image/jpeg";
|
||||
} else if (lowerName.endsWith(".png")) {
|
||||
return MediaType.IMAGE_PNG_VALUE;
|
||||
return "image/png";
|
||||
} else {
|
||||
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,8 +551,8 @@ public class TaskManager {
|
||||
if (jobStore != null) {
|
||||
// Propagate JobStore failures: returning null on a backplane outage would conflate
|
||||
// "no such file" with "lookup unavailable" and the caller would respond 404 to a
|
||||
// transient blip that should be retried. Let Spring's exception handler surface a
|
||||
// 5xx so clients know to retry.
|
||||
// transient blip that should be retried. Let the framework's exception handler
|
||||
// surface a 5xx so clients know to retry.
|
||||
try {
|
||||
return jobStore.findJobIdByFileId(fileId).orElse(null);
|
||||
} catch (RuntimeException e) {
|
||||
|
||||
+19
-14
@@ -5,18 +5,17 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import io.quarkus.scheduler.Scheduled;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Named;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -32,7 +31,7 @@ import stirling.software.common.util.TempFileRegistry;
|
||||
* and directories.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ApplicationScoped
|
||||
@RequiredArgsConstructor
|
||||
public class TempFileCleanupService {
|
||||
|
||||
@@ -40,9 +39,9 @@ public class TempFileCleanupService {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("machineType")
|
||||
private String machineType;
|
||||
@Inject
|
||||
@Named("machineType")
|
||||
String machineType;
|
||||
|
||||
// Maximum recursion depth for directory traversal
|
||||
private static final int MAX_RECURSION_DEPTH = 5;
|
||||
@@ -127,11 +126,17 @@ public class TempFileCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Scheduled task to clean up old temporary files. Runs at the configured interval. */
|
||||
@Scheduled(
|
||||
fixedDelayString =
|
||||
"#{applicationProperties.system.tempFileManagement.cleanupIntervalMinutes}",
|
||||
timeUnit = TimeUnit.MINUTES)
|
||||
/**
|
||||
* Scheduled task to clean up old temporary files. Runs at the configured interval.
|
||||
*
|
||||
* <p>TODO: Migration required - the Spring form used a SpEL expression ({@code
|
||||
* fixedDelayString="#{applicationProperties.system.tempFileManagement.cleanupIntervalMinutes}"}).
|
||||
* Quarkus {@code @Scheduled} cannot reference an arbitrary bean property; {@code every} only
|
||||
* resolves a MicroProfile Config placeholder. The cleanup interval must therefore be exposed as
|
||||
* a config key (e.g. {@code stirling.temp.cleanup-interval}) bound to the same value, and the
|
||||
* minutes->duration mapping handled in config. Default below is 30m.
|
||||
*/
|
||||
@Scheduled(every = "{stirling.temp.cleanup-interval:30m}")
|
||||
public void scheduledCleanup() {
|
||||
log.info("Running scheduled temporary file cleanup");
|
||||
long maxAgeMillis = tempFileManager.getMaxAgeMillis();
|
||||
|
||||
@@ -3,27 +3,33 @@ package stirling.software.common.util;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
import io.quarkus.runtime.StartupEvent;
|
||||
import io.quarkus.runtime.annotations.CommandLineArguments;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.event.Observes;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Captures application command-line arguments at startup so they can be reused for restart
|
||||
* operations. This allows the application to restart with the same configuration.
|
||||
*
|
||||
* <p>MIGRATION (Spring -> Quarkus): replaced Spring's {@code ApplicationRunner}/{@code
|
||||
* ApplicationArguments} with a CDI startup observer ({@code @Observes StartupEvent}) and Quarkus'
|
||||
* {@code @CommandLineArguments String[]} injection.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AppArgsCapture implements ApplicationRunner {
|
||||
@ApplicationScoped
|
||||
public class AppArgsCapture {
|
||||
|
||||
public static final AtomicReference<List<String>> APP_ARGS = new AtomicReference<>(List.of());
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
APP_ARGS.set(List.of(args.getSourceArgs()));
|
||||
log.debug(
|
||||
"Captured {} application arguments for restart capability",
|
||||
args.getSourceArgs().length);
|
||||
@Inject @CommandLineArguments String[] args;
|
||||
|
||||
void onStart(@Observes StartupEvent event) {
|
||||
APP_ARGS.set(List.of(args));
|
||||
log.debug("Captured {} application arguments for restart capability", args.length);
|
||||
}
|
||||
}
|
||||
|
||||
+26
-25
@@ -1,23 +1,17 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.stereotype.Component;
|
||||
import io.quarkus.arc.Arc;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Instance;
|
||||
import jakarta.enterprise.inject.literal.NamedLiteral;
|
||||
|
||||
/**
|
||||
* Helper class that provides access to the ApplicationContext. Useful for getting beans in classes
|
||||
* that are not managed by Spring.
|
||||
* Helper class that provides access to the CDI container. Useful for getting beans in classes that
|
||||
* are not managed by CDI.
|
||||
*/
|
||||
@Component
|
||||
public class ApplicationContextProvider implements ApplicationContextAware {
|
||||
|
||||
private static ApplicationContext applicationContext;
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext context) throws BeansException {
|
||||
applicationContext = context;
|
||||
}
|
||||
@ApplicationScoped
|
||||
public class ApplicationContextProvider {
|
||||
|
||||
/**
|
||||
* Get a bean by class type.
|
||||
@@ -27,12 +21,16 @@ public class ApplicationContextProvider implements ApplicationContextAware {
|
||||
* @return The bean instance, or null if not found
|
||||
*/
|
||||
public static <T> T getBean(Class<T> beanClass) {
|
||||
if (applicationContext == null) {
|
||||
if (Arc.container() == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return applicationContext.getBean(beanClass);
|
||||
} catch (BeansException e) {
|
||||
Instance<T> instance = Arc.container().select(beanClass);
|
||||
if (instance.isResolvable()) {
|
||||
return instance.get();
|
||||
}
|
||||
return null;
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -46,12 +44,16 @@ public class ApplicationContextProvider implements ApplicationContextAware {
|
||||
* @return The bean instance, or null if not found
|
||||
*/
|
||||
public static <T> T getBean(String name, Class<T> beanClass) {
|
||||
if (applicationContext == null) {
|
||||
if (Arc.container() == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return applicationContext.getBean(name, beanClass);
|
||||
} catch (BeansException e) {
|
||||
Instance<T> instance = Arc.container().select(beanClass, NamedLiteral.of(name));
|
||||
if (instance.isResolvable()) {
|
||||
return instance.get();
|
||||
}
|
||||
return null;
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -63,13 +65,12 @@ public class ApplicationContextProvider implements ApplicationContextAware {
|
||||
* @return true if the bean exists, false otherwise
|
||||
*/
|
||||
public static boolean containsBean(Class<?> beanClass) {
|
||||
if (applicationContext == null) {
|
||||
if (Arc.container() == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
applicationContext.getBean(beanClass);
|
||||
return true;
|
||||
} catch (BeansException e) {
|
||||
return Arc.container().select(beanClass).isResolvable();
|
||||
} catch (RuntimeException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.github.junrar.Archive;
|
||||
import com.github.junrar.exception.CorruptHeaderException;
|
||||
@@ -24,6 +23,7 @@ import com.github.junrar.rarfile.FileHeader;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@Slf4j
|
||||
|
||||
@@ -20,11 +20,11 @@ import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@Slf4j
|
||||
|
||||
@@ -4,12 +4,13 @@ import org.owasp.html.AttributePolicy;
|
||||
import org.owasp.html.HtmlPolicyBuilder;
|
||||
import org.owasp.html.PolicyFactory;
|
||||
import org.owasp.html.Sanitizers;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.SsrfProtectionService;
|
||||
|
||||
@Component
|
||||
@ApplicationScoped
|
||||
public class CustomHtmlSanitizer {
|
||||
|
||||
private final SsrfProtectionService ssrfProtectionService;
|
||||
|
||||
@@ -10,9 +10,6 @@ import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import lombok.Synchronized;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -46,10 +43,10 @@ public class EmlProcessingUtils {
|
||||
};
|
||||
private final Map<String, String> EXTENSION_TO_MIME_TYPE =
|
||||
Map.of(
|
||||
".png", MediaType.IMAGE_PNG_VALUE,
|
||||
".jpg", MediaType.IMAGE_JPEG_VALUE,
|
||||
".jpeg", MediaType.IMAGE_JPEG_VALUE,
|
||||
".gif", MediaType.IMAGE_GIF_VALUE,
|
||||
".png", "image/png",
|
||||
".jpg", "image/jpeg",
|
||||
".jpeg", "image/jpeg",
|
||||
".gif", "image/gif",
|
||||
".bmp", "image/bmp",
|
||||
".webp", "image/webp",
|
||||
".svg", "image/svg+xml",
|
||||
@@ -112,8 +109,8 @@ public class EmlProcessingUtils {
|
||||
|| lowerContent.contains("bcc:");
|
||||
boolean hasMimeStructure =
|
||||
lowerContent.contains("multipart/")
|
||||
|| lowerContent.contains(MediaType.TEXT_PLAIN_VALUE)
|
||||
|| lowerContent.contains(MediaType.TEXT_HTML_VALUE)
|
||||
|| lowerContent.contains("text/plain")
|
||||
|| lowerContent.contains("text/html")
|
||||
|| lowerContent.contains("boundary=");
|
||||
|
||||
int headerCount = 0;
|
||||
@@ -328,8 +325,13 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
|
||||
try {
|
||||
ClassPathResource resource = new ClassPathResource(CSS_RESOURCE_PATH);
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
try (InputStream inputStream =
|
||||
EmlProcessingUtils.class
|
||||
.getClassLoader()
|
||||
.getResourceAsStream(CSS_RESOURCE_PATH)) {
|
||||
if (inputStream == null) {
|
||||
throw new IOException("Resource not found: " + CSS_RESOURCE_PATH);
|
||||
}
|
||||
cachedCssContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
return cachedCssContent;
|
||||
}
|
||||
@@ -454,7 +456,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
return MediaType.IMAGE_PNG_VALUE; // Default MIME type
|
||||
return "image/png"; // Default MIME type
|
||||
}
|
||||
|
||||
public String decodeUrlEncoded(String encoded) {
|
||||
|
||||
@@ -2,30 +2,38 @@ package stirling.software.common.util;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ErrorUtils {
|
||||
|
||||
public static Model exceptionToModel(Model model, Exception ex) {
|
||||
// TODO: Migration required - server-rendered error view removed; surface via JAX-RS
|
||||
// ExceptionMapper. Spring MVC org.springframework.ui.Model has no Quarkus/Jakarta (JAX-RS)
|
||||
// drop-in; the method now mutates and returns a plain Map<String, Object> model holder.
|
||||
public static Map<String, Object> exceptionToModel(Map<String, Object> model, Exception ex) {
|
||||
StringWriter sw = new StringWriter();
|
||||
ex.printStackTrace(new PrintWriter(sw));
|
||||
String stackTrace = sw.toString();
|
||||
|
||||
model.addAttribute("errorMessage", ex.getMessage());
|
||||
model.addAttribute("stackTrace", stackTrace);
|
||||
model.put("errorMessage", ex.getMessage());
|
||||
model.put("stackTrace", stackTrace);
|
||||
return model;
|
||||
}
|
||||
|
||||
public static ModelAndView exceptionToModelView(Model model, Exception ex) {
|
||||
// TODO: Migration required - server-rendered error view removed; surface via JAX-RS
|
||||
// ExceptionMapper. Spring MVC org.springframework.web.servlet.ModelAndView has no
|
||||
// Quarkus/Jakarta (JAX-RS) drop-in; the method now returns a plain Map<String, Object> model
|
||||
// holder instead of a ModelAndView (the incoming model parameter is retained for signature
|
||||
// compatibility but is no longer the Spring Model type).
|
||||
public static Map<String, Object> exceptionToModelView(
|
||||
Map<String, Object> model, Exception ex) {
|
||||
StringWriter sw = new StringWriter();
|
||||
ex.printStackTrace(new PrintWriter(sw));
|
||||
String stackTrace = sw.toString();
|
||||
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.addObject("errorMessage", ex.getMessage());
|
||||
modelAndView.addObject("stackTrace", stackTrace);
|
||||
Map<String, Object> modelAndView = new HashMap<>();
|
||||
modelAndView.put("errorMessage", ex.getMessage());
|
||||
modelAndView.put("stackTrace", stackTrace);
|
||||
return modelAndView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,16 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import io.quarkus.scheduler.Scheduled;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Named;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
|
||||
@Component
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class FileMonitor {
|
||||
|
||||
@@ -38,7 +39,7 @@ public class FileMonitor {
|
||||
* monitored, false otherwise
|
||||
*/
|
||||
public FileMonitor(
|
||||
@Qualifier("directoryFilter") Predicate<Path> pathFilter,
|
||||
@Named("directoryFilter") Predicate<Path> pathFilter,
|
||||
RuntimePathConfig runtimePathConfig)
|
||||
throws IOException {
|
||||
this.newlyDiscoveredFiles = new HashSet<>();
|
||||
@@ -106,7 +107,7 @@ public class FileMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 5000)
|
||||
@Scheduled(every = "5s")
|
||||
public void trackFiles() {
|
||||
/*
|
||||
All files observed changes in the last iteration will be considered as staging files.
|
||||
|
||||
@@ -10,7 +10,7 @@ import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -40,7 +40,7 @@ import stirling.software.common.model.ApplicationProperties.AutoPipeline.FileRea
|
||||
* ApplicationProperties.AutoPipeline}. Setting {@code enabled: false} makes every call return
|
||||
* {@code true} so the checker is a no-op drop-in.
|
||||
*/
|
||||
@Component
|
||||
@ApplicationScoped
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FileReadinessChecker {
|
||||
|
||||
@@ -15,12 +15,6 @@ import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.core.io.support.ResourcePatternUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fathzer.soft.javaluator.DoubleEvaluator;
|
||||
|
||||
import io.github.pixee.security.HostValidator;
|
||||
@@ -30,6 +24,9 @@ import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
import stirling.software.common.model.io.FileSystemResource;
|
||||
import stirling.software.common.model.io.Resource;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
@@ -248,18 +245,60 @@ public class GeneralUtils {
|
||||
return safeName;
|
||||
}
|
||||
|
||||
// Get resources from a location pattern
|
||||
public Resource[] getResourcesFromLocationPattern(
|
||||
String locationPattern, ResourceLoader resourceLoader) throws Exception {
|
||||
// Normalize the path for file resources
|
||||
String pattern = locationPattern;
|
||||
if (pattern.startsWith("file:")) {
|
||||
String rawPath = pattern.substring(5).replace("\\*", "").replace("/*", "");
|
||||
Path normalizePath = Path.of(rawPath).normalize();
|
||||
pattern = "file:" + normalizePath.toString().replace("\\", "/") + "/*";
|
||||
/**
|
||||
* Resolve files matching a location pattern. Supports {@code file:<dir>/<glob>} and {@code
|
||||
* classpath:<dir>/<glob>} (e.g. {@code *} or {@code *.woff2}).
|
||||
*
|
||||
* <p>MIGRATION (Spring -> Quarkus): replaced Spring's {@code ResourceLoader} + {@code
|
||||
* ResourcePatternUtils} pattern resolver. The {@code ResourceLoader} parameter was removed.
|
||||
* {@code file:} patterns are resolved with {@link java.nio.file.Files#list}; {@code classpath:}
|
||||
* patterns are resolved via the classloader and only support directory resources that live on
|
||||
* the filesystem.
|
||||
*
|
||||
* <p>TODO: Migration required - {@code classpath:} resolution does not enumerate entries inside
|
||||
* a packaged JAR. For uber-jar deployments, prefer serving these assets from {@code
|
||||
* META-INF/resources/} or build a Jandex/build-time index of the matching files.
|
||||
*/
|
||||
public static Resource[] getResourcesFromLocationPattern(String locationPattern)
|
||||
throws Exception {
|
||||
String body = locationPattern;
|
||||
boolean classpath = false;
|
||||
if (body.startsWith("file:")) {
|
||||
body = body.substring(5);
|
||||
} else if (body.startsWith("classpath:")) {
|
||||
body = body.substring(10);
|
||||
classpath = true;
|
||||
}
|
||||
return ResourcePatternUtils.getResourcePatternResolver(resourceLoader)
|
||||
.getResources(pattern);
|
||||
body = body.replace("\\", "/");
|
||||
int lastSlash = body.lastIndexOf('/');
|
||||
String dirPart = lastSlash >= 0 ? body.substring(0, lastSlash) : "";
|
||||
String glob = lastSlash >= 0 ? body.substring(lastSlash + 1) : body;
|
||||
if (glob.isEmpty()) {
|
||||
glob = "*";
|
||||
}
|
||||
|
||||
Path dir;
|
||||
if (classpath) {
|
||||
URL url = GeneralUtils.class.getClassLoader().getResource(dirPart);
|
||||
if (url == null || !"file".equals(url.getProtocol())) {
|
||||
// Not a filesystem-backed classpath dir (e.g. inside a jar) - see TODO above.
|
||||
return new Resource[0];
|
||||
}
|
||||
dir = Paths.get(url.toURI());
|
||||
} else {
|
||||
dir = Paths.get(dirPart).normalize();
|
||||
}
|
||||
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return new Resource[0];
|
||||
}
|
||||
PathMatcher matcher = dir.getFileSystem().getPathMatcher("glob:" + glob);
|
||||
List<Resource> resources = new ArrayList<>();
|
||||
try (var stream = Files.list(dir)) {
|
||||
stream.filter(p -> Files.isRegularFile(p) && matcher.matches(p.getFileName()))
|
||||
.forEach(p -> resources.add(new FileSystemResource(p)));
|
||||
}
|
||||
return resources.toArray(new Resource[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -837,7 +876,7 @@ public class GeneralUtils {
|
||||
}
|
||||
|
||||
public boolean createDir(String path) {
|
||||
Path folder = Path.of(path);
|
||||
Path folder = Paths.get(path);
|
||||
if (!Files.exists(folder)) {
|
||||
try {
|
||||
Files.createDirectories(folder);
|
||||
@@ -867,7 +906,7 @@ public class GeneralUtils {
|
||||
|
||||
public void saveKeyToSettings(String key, Object newValue) throws IOException {
|
||||
String[] keyArray = key.split("\\.");
|
||||
Path settingsPath = Path.of(InstallationPathConfig.getSettingsPath());
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||
settingsYaml.updateValue(Arrays.asList(keyArray), newValue);
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
@@ -888,7 +927,7 @@ public class GeneralUtils {
|
||||
return;
|
||||
}
|
||||
|
||||
Path settingsPath = Path.of(InstallationPathConfig.getSettingsPath());
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||
|
||||
// Apply all updates to the same YamlHelper instance
|
||||
@@ -974,23 +1013,21 @@ public class GeneralUtils {
|
||||
*/
|
||||
public void extractPipeline() throws IOException {
|
||||
Path pipelineDir =
|
||||
Path.of(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR);
|
||||
Paths.get(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR);
|
||||
Files.createDirectories(pipelineDir);
|
||||
|
||||
for (String name : DEFAULT_VALID_PIPELINE) {
|
||||
if (!Path.of(name).getFileName().toString().equals(name)) {
|
||||
if (!Paths.get(name).getFileName().toString().equals(name)) {
|
||||
log.error("Invalid pipeline file name: {}", name);
|
||||
throw new IllegalArgumentException("Invalid pipeline file name: " + name);
|
||||
}
|
||||
Path target = pipelineDir.resolve(name);
|
||||
ClassPathResource res =
|
||||
new ClassPathResource(
|
||||
"static/pipeline/" + DEFAULT_WEBUI_CONFIGS_DIR + "/" + name);
|
||||
if (!res.exists()) {
|
||||
log.error("Resource not found: {}", res.getPath());
|
||||
throw new IOException("Resource not found: " + res.getPath());
|
||||
String resourcePath = "static/pipeline/" + DEFAULT_WEBUI_CONFIGS_DIR + "/" + name;
|
||||
if (GeneralUtils.class.getClassLoader().getResource(resourcePath) == null) {
|
||||
log.error("Resource not found: {}", resourcePath);
|
||||
throw new IOException("Resource not found: " + resourcePath);
|
||||
}
|
||||
copyResourceToFile(res, target);
|
||||
copyResourceToFile(resourcePath, target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,7 +1051,7 @@ public class GeneralUtils {
|
||||
throw new IllegalArgumentException(
|
||||
"scriptName must not contain path traversal characters");
|
||||
}
|
||||
if (!Path.of(scriptName).getFileName().toString().equals(scriptName)) {
|
||||
if (!Paths.get(scriptName).getFileName().toString().equals(scriptName)) {
|
||||
throw new IllegalArgumentException(
|
||||
"scriptName must not contain path traversal characters");
|
||||
}
|
||||
@@ -1024,31 +1061,34 @@ public class GeneralUtils {
|
||||
"scriptName must be either 'png_to_webp.py' or 'split_photos.py'");
|
||||
}
|
||||
|
||||
Path scriptsDir = Path.of(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR);
|
||||
Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR);
|
||||
Files.createDirectories(scriptsDir);
|
||||
|
||||
Path target = scriptsDir.resolve(scriptName);
|
||||
ClassPathResource res =
|
||||
new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName);
|
||||
if (!res.exists()) {
|
||||
log.error("Resource not found: {}", res.getPath());
|
||||
throw new IOException("Resource not found: " + res.getPath());
|
||||
String resourcePath = "static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName;
|
||||
if (GeneralUtils.class.getClassLoader().getResource(resourcePath) == null) {
|
||||
log.error("Resource not found: {}", resourcePath);
|
||||
throw new IOException("Resource not found: " + resourcePath);
|
||||
}
|
||||
copyResourceToFile(res, target);
|
||||
copyResourceToFile(resourcePath, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
/*
|
||||
* Copies a resource from the classpath to a specified target file.
|
||||
* Copies a classpath resource to a specified target file.
|
||||
*
|
||||
* @param resource the ClassPathResource to copy
|
||||
* @param resourcePath the classpath resource location to copy
|
||||
* @param target the target Path where the resource will be copied
|
||||
* @throws IOException if an I/O error occurs during the copy operation
|
||||
*/
|
||||
private void copyResourceToFile(ClassPathResource resource, Path target) throws IOException {
|
||||
private void copyResourceToFile(String resourcePath, Path target) throws IOException {
|
||||
Path dir = target.getParent();
|
||||
Path tmp = Files.createTempFile(dir, target.getFileName().toString(), ".tmp");
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
try (InputStream in =
|
||||
GeneralUtils.class.getClassLoader().getResourceAsStream(resourcePath)) {
|
||||
if (in == null) {
|
||||
throw new IOException("Resource not found: " + resourcePath);
|
||||
}
|
||||
Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING);
|
||||
try {
|
||||
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
|
||||
@@ -12,8 +12,6 @@ import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.drew.imaging.ImageMetadataReader;
|
||||
import com.drew.imaging.ImageProcessingException;
|
||||
import com.drew.metadata.Metadata;
|
||||
@@ -22,6 +20,8 @@ import com.drew.metadata.exif.ExifSubIFDDirectory;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.MultipartFile;
|
||||
|
||||
@Slf4j
|
||||
public class ImageProcessingUtils {
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import javax.xml.transform.TransformerFactory;
|
||||
import javax.xml.transform.dom.DOMSource;
|
||||
import javax.xml.transform.stream.StreamResult;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NamedNodeMap;
|
||||
@@ -32,13 +31,15 @@ import org.xml.sax.SAXException;
|
||||
|
||||
import io.github.pixee.security.ZipSecurity;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.SsrfProtectionService;
|
||||
|
||||
// Strips external refs from OOXML/ODF uploads so LibreOffice can't be made to fetch them.
|
||||
@Component
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class OfficeDocumentSanitizer {
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user