Compare commits
75
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/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.14.0
|
||||
pkgver=2.13.0
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.14.0
|
||||
pkgver=2.13.0
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Python
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
cache-disabled: true
|
||||
|
||||
# No `-PnoSpotless` here yet because the upstream cache layer matches the
|
||||
@@ -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
|
||||
|
||||
@@ -61,22 +61,14 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
cache-disabled: true
|
||||
|
||||
# When the PR changes the base image, test.sh builds it locally
|
||||
# (stirling-pdf-base:local) into the daemon image store. A buildx
|
||||
# container builder can't see that store, so skip it here and let
|
||||
# `docker buildx build` fall back to the default docker driver, which
|
||||
# resolves the local base. The gha cache backend is also skipped (its
|
||||
# runtime token isn't exposed) since the docker driver can't use it.
|
||||
- name: Set up Docker Buildx
|
||||
if: inputs.docker-base-changed != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
if: inputs.docker-base-changed != 'true'
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||
|
||||
- name: Install Docker Compose
|
||||
|
||||
@@ -349,7 +349,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
@@ -252,7 +252,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -41,11 +41,6 @@ jobs:
|
||||
- name: Install all Playwright browsers
|
||||
run: task e2e:install
|
||||
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
|
||||
- name: Run E2E tests (all browsers)
|
||||
run: task e2e:cross-browser
|
||||
|
||||
@@ -56,16 +51,3 @@ jobs:
|
||||
name: playwright-nightly-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
# Builds all desktop platforms on a schedule so the Rust dependency cache is
|
||||
# written on main, where PR and merge-queue tauri builds can restore it.
|
||||
warm-tauri-cache:
|
||||
name: Warm Tauri Rust cache
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/tauri-build.yml
|
||||
with:
|
||||
platform: all
|
||||
sign: false
|
||||
secrets: inherit
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
@@ -155,9 +155,9 @@ jobs:
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# No BASE_VERSION pin: inherit the Dockerfile ARG default (single source of truth).
|
||||
build-args: |
|
||||
VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
BASE_VERSION=1.0.0
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@@ -16,11 +16,6 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: "all"
|
||||
sign:
|
||||
description: "Sign and notarize the bundles."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
@@ -33,11 +28,6 @@ on:
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
sign:
|
||||
description: "Sign and notarize the bundles."
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -125,20 +115,6 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
# Cache the Cargo registry and compiled dependency crates so the build
|
||||
# only recompiles the app crate. Written on main; PRs and the merge queue
|
||||
# restore from it.
|
||||
- name: Cache Rust build
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: frontend/editor/src-tauri
|
||||
# Stable key shared across workflows so the nightly warmer.
|
||||
# rust-cache still appends OS + rustc + Cargo.lock.
|
||||
shared-key: tauri-${{ matrix.name }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# Save the dependency cache even if a later step fails
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
@@ -160,7 +136,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -187,7 +163,7 @@ jobs:
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
@@ -197,7 +173,7 @@ jobs:
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
@@ -232,7 +208,7 @@ jobs:
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
@@ -263,7 +239,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -284,7 +260,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -307,7 +283,7 @@ jobs:
|
||||
ls -la /usr/bin/hd* || echo "No hd* tools found"
|
||||
|
||||
- name: Preflight smctl
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -320,7 +296,7 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" }
|
||||
|
||||
- name: Configure Windows code signing
|
||||
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: bash
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -339,7 +315,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Import release GPG signing key (Linux)
|
||||
if: inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
|
||||
gpg --list-secret-keys --keyid-format=long
|
||||
@@ -356,8 +332,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Tauri app (signed)
|
||||
if: inputs.sign
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -391,26 +366,6 @@ jobs:
|
||||
# failure (#6127 onwards) does not tank deb/rpm uploads.
|
||||
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
|
||||
|
||||
- name: Build Tauri app (unsigned)
|
||||
if: ${{ !inputs.sign }}
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SIGN: "0"
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend/editor
|
||||
tauriScript: npx tauri
|
||||
# Linux: build deb+rpm only here. AppImage runs in its own
|
||||
# continue-on-error step below so its persistent linuxdeploy
|
||||
# failure (#6127 onwards) does not tank deb/rpm uploads.
|
||||
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
|
||||
|
||||
# AppImage is decoupled so its linuxdeploy run gets a fresh process
|
||||
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
|
||||
- name: Build Tauri app (Linux AppImage)
|
||||
@@ -419,7 +374,7 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
|
||||
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -434,7 +389,7 @@ jobs:
|
||||
args: --bundles appimage
|
||||
|
||||
- name: Clear release GPG key from runner keyring (Linux)
|
||||
if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
run: |
|
||||
@@ -444,7 +399,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: inputs.sign && matrix.platform == 'macos-15'
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "🔍 Verifying notarization status..."
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
@@ -482,7 +437,7 @@ jobs:
|
||||
# Verify the MSI AND the inner exe extracted from it are signed.
|
||||
# The inner exe is what gets installed on users' machines and what AV scans.
|
||||
- name: Verify Windows Code Signature
|
||||
if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
|
||||
if: matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$allSigned = $true
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
@@ -155,19 +155,6 @@ jobs:
|
||||
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Base-changed PRs build the embedded image with the local docker driver
|
||||
# so the locally-built stirling-pdf-base:pr-test (in the daemon image
|
||||
# store) resolves. A buildx container builder cannot see it and would try
|
||||
# to pull it from a registry, which fails. Single-platform, no gha cache.
|
||||
- name: Build ${{ matrix.docker-rev }} against local base (PR base change)
|
||||
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
|
||||
run: |
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--build-arg BASE_IMAGE=${{ steps.build-params.outputs.base_image }} \
|
||||
--file ./${{ matrix.docker-rev }} \
|
||||
--tag stirling-pdf-embedded:pr-test \
|
||||
.
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }} (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
@@ -182,10 +169,8 @@ jobs:
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
# Fork PRs that did NOT change the base use the buildx container builder
|
||||
# (multi-platform + gha cache) against the published base image.
|
||||
- name: Build ${{ matrix.docker-rev }} (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true'
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
|
||||
+25
-6
@@ -42,14 +42,18 @@ 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
|
||||
!app/core/src/main/resources/static/mobile-upload.html
|
||||
# Prerendered nested-route pages (e.g. settings/people.html)
|
||||
app/core/src/main/resources/static/settings/
|
||||
app/core/src/main/resources/static/locales/
|
||||
@@ -67,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
|
||||
@@ -281,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/
|
||||
|
||||
@@ -19,11 +19,3 @@ frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
|
||||
|
||||
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
|
||||
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
|
||||
|
||||
# False positive: generic-api-key matches the Java type name "X509Certificate"
|
||||
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
|
||||
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
|
||||
|
||||
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
|
||||
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
|
||||
.github/workflows/tauri-build.yml:generic-api-key:402
|
||||
|
||||
+2
-10
@@ -25,29 +25,21 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
|
||||
POLICIES_ENABLED: '{{.POLICIES_ENABLED}}'
|
||||
|
||||
dev:proprietary:
|
||||
desc: "Start backend dev server in proprietary mode"
|
||||
# `dotenv:` reads from the root Taskfile's directory (".") because this
|
||||
# subtaskfile is included with `dir: .`. Local overrides in
|
||||
# .env.proprietary.local win over the committed .env.proprietary defaults.
|
||||
dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary']
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
|
||||
POLICIES_ENABLED: '{{.POLICIES_ENABLED | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
|
||||
+9
-48
@@ -5,11 +5,6 @@ vars:
|
||||
# NoClassDefFoundError: jdk/dynalink/Namespace at runtime in get-info-on-pdf and verify-pdf
|
||||
JLINK_MODULES: "java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported,jdk.dynalink"
|
||||
|
||||
# Minimum Java major the bundled JRE must be. Keep in sync with build.gradle
|
||||
# `modernJavaVersion` - the app JAR is compiled for this, so an older runtime
|
||||
# fails at launch with UnsupportedClassVersionError. Enforced by jlink:verify.
|
||||
REQUIRED_JAVA: "25"
|
||||
|
||||
# Override via JPDFIUM_PLATFORMS env (csv of platform keys, or 'all').
|
||||
JPDFIUM_PLATFORMS:
|
||||
sh: |
|
||||
@@ -107,20 +102,6 @@ tasks:
|
||||
jlink:
|
||||
desc: "Build backend JAR and create JLink runtime for Tauri"
|
||||
deps: [jlink:jar, jlink:runtime]
|
||||
# Runs after the runtime is in place. Lives here (not in jlink:runtime's
|
||||
# cmds) so it still fires when jlink:runtime short-circuits on its `status:`
|
||||
# check and reuses an existing runtime/jre - that reuse path is exactly how
|
||||
# a stale, too-old JRE slips through.
|
||||
cmds:
|
||||
- task: jlink:verify
|
||||
|
||||
jlink:verify:
|
||||
desc: "Fail the build if the bundled JRE is older than the app JAR requires"
|
||||
dir: editor
|
||||
env:
|
||||
REQUIRED_JAVA: "{{.REQUIRED_JAVA}}"
|
||||
cmds:
|
||||
- node scripts/verify-bundled-jre.mjs src-tauri/runtime/jre/release
|
||||
|
||||
jlink:jar:
|
||||
desc: "Build backend JAR for Tauri bundling (host-OS natives only by default)"
|
||||
@@ -146,35 +127,15 @@ tasks:
|
||||
cmds:
|
||||
- rm -rf runtime/jre
|
||||
- mkdir -p runtime
|
||||
# Pin jlink to JAVA_HOME so the bundled JRE matches the JDK the build
|
||||
# uses. Bare `jlink` on PATH can resolve to an older system Java (the
|
||||
# ubuntu runner ships Java 11), producing a runtime jlink:verify rejects.
|
||||
#
|
||||
# jdk.crypto.mscapi (the Windows certificate store / SunMSCAPI provider, used by
|
||||
# hardware-backed cert signing) is a Windows-only module - it only exists in a Windows
|
||||
# JDK's jmods, so it is added on Windows only or jlink fails to resolve it elsewhere.
|
||||
- cmd: |
|
||||
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
|
||||
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
|
||||
"$JLINK" \
|
||||
--add-modules {{.JLINK_MODULES}},jdk.crypto.mscapi \
|
||||
--strip-debug \
|
||||
--compress="$JLINK_COMPRESS" \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output runtime/jre
|
||||
platforms: [windows]
|
||||
- cmd: |
|
||||
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
|
||||
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
|
||||
"$JLINK" \
|
||||
--add-modules {{.JLINK_MODULES}} \
|
||||
--strip-debug \
|
||||
--compress="$JLINK_COMPRESS" \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output runtime/jre
|
||||
platforms: [linux, darwin]
|
||||
- |
|
||||
JLINK_COMPRESS="$(jlink --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
|
||||
jlink \
|
||||
--add-modules {{.JLINK_MODULES}} \
|
||||
--strip-debug \
|
||||
--compress="$JLINK_COMPRESS" \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output runtime/jre
|
||||
# jlink emits its files mode 444 (read-only). Tauri's build-script
|
||||
# resource copier preserves source permissions when staging
|
||||
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
|
||||
|
||||
+12
-87
@@ -130,34 +130,9 @@ tasks:
|
||||
|
||||
dev:portal:
|
||||
desc: "Start developer portal dev server"
|
||||
ignore_error: true
|
||||
deps: [install]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "5173"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_URL: '{{.EDITOR_URL | default ""}}'
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
MOCKS: '{{.MOCKS | default ""}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
|
||||
|
||||
dev:portal:proxy:serve:
|
||||
internal: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
|
||||
cmds:
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
- npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
@@ -178,10 +153,8 @@ tasks:
|
||||
build:proprietary:
|
||||
desc: "Build for proprietary mode"
|
||||
deps: [prepare]
|
||||
vars:
|
||||
PREVIEW: '{{.PREVIEW | default ""}}'
|
||||
cmds:
|
||||
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary'
|
||||
- npx vite build editor --mode proprietary
|
||||
|
||||
build:saas:
|
||||
desc: "Build for SaaS mode"
|
||||
@@ -208,26 +181,8 @@ tasks:
|
||||
build:portal:
|
||||
desc: "Build developer portal"
|
||||
deps: [install]
|
||||
vars:
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
cmds:
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
|
||||
deps: [prepare]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- task: build:proprietary
|
||||
vars: { PREVIEW: '1' }
|
||||
- task: build:portal
|
||||
vars: { SUBPATH: portal }
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
- npx vite build portal
|
||||
|
||||
storybook:
|
||||
desc: "Start Storybook dev server"
|
||||
@@ -295,26 +250,17 @@ tasks:
|
||||
cmds:
|
||||
- task: typecheck:proprietary
|
||||
|
||||
typecheck:_run:
|
||||
internal: true
|
||||
env:
|
||||
CI: '{{ .CI | default "false" }}'
|
||||
cmds:
|
||||
- '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}'
|
||||
|
||||
typecheck:core:
|
||||
desc: "Typecheck core build variant"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/core/tsconfig.json }
|
||||
- npx tsc --noEmit --project editor/src/core/tsconfig.json
|
||||
|
||||
typecheck:proprietary:
|
||||
desc: "Typecheck proprietary build variant"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/proprietary/tsconfig.json }
|
||||
- npx tsc --noEmit --project editor/src/proprietary/tsconfig.json
|
||||
|
||||
typecheck:saas:
|
||||
desc: "Typecheck SaaS build variant"
|
||||
@@ -322,8 +268,7 @@ tasks:
|
||||
- task: prepare
|
||||
vars: { MODE: saas }
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/saas/tsconfig.json }
|
||||
- npx tsc --noEmit --project editor/src/saas/tsconfig.json
|
||||
|
||||
typecheck:desktop:
|
||||
desc: "Typecheck desktop build variant"
|
||||
@@ -331,45 +276,37 @@ tasks:
|
||||
- task: prepare
|
||||
vars: { MODE: desktop }
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/desktop/tsconfig.json }
|
||||
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json
|
||||
|
||||
typecheck:cloud:
|
||||
desc: "Typecheck cloud shared layer (standalone)"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/cloud/tsconfig.json }
|
||||
- npx tsc --noEmit --project editor/src/cloud/tsconfig.json
|
||||
|
||||
typecheck:scripts:
|
||||
desc: "Typecheck scripts"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: scripts/tsconfig.json }
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/scripts/tsconfig.json }
|
||||
- npx tsc --noEmit --project editor/scripts/tsconfig.json
|
||||
|
||||
typecheck:prototypes:
|
||||
desc: "Typecheck prototypes build variant"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/prototypes/tsconfig.json }
|
||||
- npx tsc --noEmit --project editor/src/prototypes/tsconfig.json
|
||||
|
||||
typecheck:portal:
|
||||
desc: "Typecheck developer portal build variant"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: portal/tsconfig.json }
|
||||
- npx tsc --noEmit --project portal/tsconfig.json
|
||||
|
||||
typecheck:shared:
|
||||
desc: "Typecheck the shared design system"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: shared/tsconfig.json }
|
||||
- npx tsc --noEmit --project shared/tsconfig.json
|
||||
|
||||
typecheck:all:
|
||||
desc: "Typecheck all build variants"
|
||||
@@ -421,22 +358,10 @@ tasks:
|
||||
|
||||
test:
|
||||
desc: "Run tests"
|
||||
cmds:
|
||||
- task: test:editor
|
||||
- task: test:portal
|
||||
|
||||
test:editor:
|
||||
desc: "Run editor tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest run --root editor
|
||||
|
||||
test:portal:
|
||||
desc: "Run portal tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest run --root portal
|
||||
|
||||
test:watch:
|
||||
desc: "Run tests in watch mode"
|
||||
deps: [prepare]
|
||||
|
||||
+39
-13
@@ -4,6 +4,8 @@ version: '3'
|
||||
# pre-commit hook (.pre-commit-config.yaml) and CI (pre_commit.yml) both call.
|
||||
|
||||
vars:
|
||||
GITLEAKS: '8.30.0'
|
||||
|
||||
# File selections as git pathspecs: git does the include/exclude matching, so
|
||||
# there is no grep/xargs and it behaves identically on every platform.
|
||||
PY_FILES: >-
|
||||
@@ -41,9 +43,7 @@ vars:
|
||||
':(exclude).github/workflows/*'
|
||||
LOCALE_TOML: 'frontend/editor/public/locales/*/translation.toml'
|
||||
|
||||
# gitleaks is pinned + checksum-verified by scripts/pre-commit/install_gitleaks.py,
|
||||
# which owns the version and caches the binary here.
|
||||
GITLEAKS_BIN: '.task/bin/gitleaks{{if eq OS "windows"}}.exe{{end}}'
|
||||
GITLEAKS_BIN: '.task/bin/gitleaks-{{.GITLEAKS}}{{if eq OS "windows"}}.exe{{end}}'
|
||||
|
||||
tasks:
|
||||
default:
|
||||
@@ -84,13 +84,22 @@ tasks:
|
||||
- test -d scripts/pre-commit/.venv
|
||||
|
||||
clean:
|
||||
desc: "Remove the cached gitleaks binary and the tool virtualenv"
|
||||
desc: "Remove the cache/build artifacts"
|
||||
cmds:
|
||||
- cmd: rm -rf scripts/pre-commit/.venv .task/bin/gitleaks
|
||||
platforms: [linux, darwin]
|
||||
- cmd: cmd /c "rmdir /s /q scripts\pre-commit\.venv & del /q .task\bin\gitleaks.exe"
|
||||
platforms: [windows]
|
||||
ignore_error: true
|
||||
- task: '{{if eq OS "windows"}}clean-windows{{else}}clean-unix{{end}}'
|
||||
|
||||
clean-unix:
|
||||
internal: true
|
||||
cmds:
|
||||
- rm -rf scripts/pre-commit/.venv .task/bin/gitleaks-*
|
||||
|
||||
# On Windows, use PowerShell so it matches the same paths and tolerates absent
|
||||
# files without erroring.
|
||||
clean-windows:
|
||||
internal: true
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- powershell -NoProfile -Command "Remove-Item -Recurse -Force -ErrorAction SilentlyContinue scripts/pre-commit/.venv, .task/bin/gitleaks-*"
|
||||
|
||||
# Individual checks (hidden from `task --list`, but callable, e.g.
|
||||
# `task pre-commit:toml-sort FIX=1`). Pass FIX=1 to auto-fix where supported.
|
||||
@@ -107,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]
|
||||
@@ -116,7 +125,7 @@ tasks:
|
||||
|
||||
whitespace:
|
||||
cmds:
|
||||
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
|
||||
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}$(git ls-files {{.WS_FILES}})
|
||||
|
||||
gitleaks:
|
||||
deps: [gitleaks-bin]
|
||||
@@ -128,6 +137,23 @@ tasks:
|
||||
|
||||
gitleaks-bin:
|
||||
internal: true
|
||||
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
|
||||
desc: "Ensure the pinned gitleaks binary is cached in .task/bin"
|
||||
status:
|
||||
- test -f {{.GITLEAKS_BIN}}
|
||||
vars:
|
||||
GL_ARCH: '{{if eq ARCH "amd64"}}x64{{else if eq ARCH "arm64"}}arm64{{else if eq ARCH "386"}}x32{{else}}{{ARCH}}{{end}}'
|
||||
GL_PLATFORM: '{{OS}}_{{.GL_ARCH}}'
|
||||
GL_URL: 'https://github.com/gitleaks/gitleaks/releases/download/v{{.GITLEAKS}}/gitleaks_{{.GITLEAKS}}_{{.GL_PLATFORM}}'
|
||||
# SHA-256 of each release asset, from gitleaks_{{.GITLEAKS}}_checksums.txt.
|
||||
GL_SHA: >-
|
||||
{{if eq .GL_PLATFORM "linux_x64"}}79a3ab579b53f71efd634f3aaf7e04a0fa0cf206b7ed434638d1547a2470a66e
|
||||
{{- else if eq .GL_PLATFORM "linux_arm64"}}b4cbbb6ddf7d1b2a603088cd03a4e3f7ce48ee7fd449b51f7de6ee2906f5fa2f
|
||||
{{- else if eq .GL_PLATFORM "darwin_x64"}}ca221d012d247080c2f6f61f4b7a83bffa2453806b0c195c795bbe9a8c775ed5
|
||||
{{- else if eq .GL_PLATFORM "darwin_arm64"}}b251ab2bcd4cd8ba9e56ff37698c033ebf38582b477d21ebd86586d927cf87e7
|
||||
{{- else if eq .GL_PLATFORM "windows_x64"}}54fe94f644b832dd08e8c3a5915efb3bfa862386d59fb27ca0792cb687a83573
|
||||
{{- end}}
|
||||
cmds:
|
||||
- uv run --no-project python scripts/pre-commit/install_gitleaks.py
|
||||
- cmd: bash scripts/pre-commit/install-gitleaks.sh "{{.GL_URL}}.tar.gz" "{{.GL_SHA}}" "{{.GITLEAKS_BIN}}"
|
||||
platforms: [linux, darwin]
|
||||
- cmd: powershell -NoProfile -File scripts/pre-commit/install-gitleaks.ps1 -Url "{{.GL_URL}}.zip" -Sha "{{.GL_SHA}}" -Dest "{{.GITLEAKS_BIN}}"
|
||||
platforms: [windows]
|
||||
|
||||
@@ -152,7 +152,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
|
||||
For a broader explanation of the frontend layering and override architecture, see [frontend/editor/DeveloperGuide.md](frontend/editor/DeveloperGuide.md).
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
|
||||
@@ -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.**
|
||||
-122
@@ -78,110 +78,6 @@ tasks:
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:
|
||||
desc: "Start backend + developer portal concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:all:
|
||||
desc: "Start backend + developer portal + editor concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
# Point the portal's "Editor" app switcher at the editor we spawn here.
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
dev:portal:all:saas:
|
||||
desc: "Start SaaS backend + developer portal + editor concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
deps:
|
||||
- task: backend:dev:saas
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
dev:portal:proxy:
|
||||
desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
SUBPATH: portal
|
||||
MOCKS: 'false'
|
||||
- task: frontend:dev:portal:proxy:serve
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
|
||||
PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
|
||||
|
||||
dev:saas:
|
||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||
cmds:
|
||||
@@ -228,24 +124,6 @@ tasks:
|
||||
- task: backend:build
|
||||
- task: frontend:build
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:preview:portal:proxy
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
# ============================================================
|
||||
# Test
|
||||
# ============================================================
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary
|
||||
# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored).
|
||||
|
||||
# Combined-billing account link (Mode A). Feature-flagged: OFF until release.
|
||||
# Flip to true in app/.env.proprietary.local to test linking locally.
|
||||
STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false
|
||||
# SaaS base URL the linked instance calls (register + entitlement).
|
||||
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app
|
||||
@@ -1,4 +1,3 @@
|
||||
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
|
||||
# stays ignored via the root .gitignore.
|
||||
!.env.saas
|
||||
!.env.proprietary
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"allowedLicenses": [
|
||||
{
|
||||
"moduleName": "org.jboss:jboss-transaction-spi",
|
||||
"moduleLicense": "Public Domain"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "BSD License"
|
||||
|
||||
+40
-13
@@ -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'
|
||||
@@ -29,13 +26,34 @@ spotless {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
api "com.google.guava:guava:${guavaVersion}"
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
api 'com.google.guava:guava:33.6.0-jre'
|
||||
|
||||
// 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'
|
||||
api "org.apache.commons:commons-lang3:${commonsLang3}"
|
||||
api 'org.apache.commons:commons-lang3:3.20.0'
|
||||
api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor
|
||||
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
|
||||
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
|
||||
@@ -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
|
||||
@@ -60,7 +79,7 @@ dependencies {
|
||||
exclude group: 'com.google.code.gson', module: 'gson'
|
||||
}
|
||||
|
||||
api "com.stirling:jpdfium:${jpdfiumVersion}"
|
||||
api 'com.stirling:jpdfium:1.0.2'
|
||||
|
||||
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
|
||||
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
|
||||
@@ -75,12 +94,20 @@ dependencies {
|
||||
}
|
||||
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
|
||||
jpdfiumPlatforms.each { platform ->
|
||||
runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}"
|
||||
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:${bucket4jVersion}"
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
|
||||
|
||||
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
|
||||
testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}"
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+57
-91
@@ -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
|
||||
@@ -206,11 +178,6 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Policies {
|
||||
/**
|
||||
* Master switch for the policy + sources subsystem (the PAYG-metered automation surface).
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* Absolute directories that policy folder input sources and output sinks may read from or
|
||||
* write to. Empty (the default) disables folder access entirely, so a policy can never be
|
||||
@@ -519,14 +486,6 @@ public class ApplicationProperties {
|
||||
private String accessibilityStatement;
|
||||
private String cookiePolicy;
|
||||
private String impressum;
|
||||
private LoginAgreement loginAgreement = new LoginAgreement();
|
||||
|
||||
@Data
|
||||
public static class LoginAgreement {
|
||||
private boolean enabled = false;
|
||||
private boolean showInAnonymousMode = true;
|
||||
private String fallbackText = "";
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -535,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();
|
||||
@@ -595,7 +558,7 @@ public class ApplicationProperties {
|
||||
public static class SAML2 {
|
||||
private String provider;
|
||||
private Boolean enabled = false;
|
||||
private Boolean autoCreateUser = true;
|
||||
private Boolean autoCreateUser = false;
|
||||
private Boolean blockRegistration = false;
|
||||
private String registrationId = "stirling";
|
||||
|
||||
@@ -621,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);
|
||||
@@ -635,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;
|
||||
@@ -645,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;
|
||||
@@ -655,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;
|
||||
@@ -672,7 +645,7 @@ public class ApplicationProperties {
|
||||
private String issuer;
|
||||
private String clientId;
|
||||
@ToString.Exclude private String clientSecret;
|
||||
private Boolean autoCreateUser = true;
|
||||
private Boolean autoCreateUser = false;
|
||||
private Boolean blockRegistration = false;
|
||||
private String useAsUsername;
|
||||
private Collection<String> scopes = new ArrayList<>();
|
||||
@@ -743,6 +716,7 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Jwt {
|
||||
private boolean enableKeystore = true;
|
||||
private boolean enableKeyRotation = false;
|
||||
private boolean enableKeyCleanup = true;
|
||||
|
||||
/**
|
||||
@@ -846,8 +820,8 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Trust {
|
||||
private boolean serverAsAnchor = true;
|
||||
private boolean useSystemTrust = true;
|
||||
private boolean useMozillaBundle = true;
|
||||
private boolean useSystemTrust = false;
|
||||
private boolean useMozillaBundle = false;
|
||||
private boolean useAATL = false;
|
||||
private boolean useEUTL = false;
|
||||
}
|
||||
@@ -881,8 +855,8 @@ public class ApplicationProperties {
|
||||
public static class System {
|
||||
private String defaultLocale;
|
||||
private boolean googlevisibility;
|
||||
private boolean showUpdate = true;
|
||||
private boolean showUpdateOnlyAdmin = true;
|
||||
private boolean showUpdate;
|
||||
private boolean showUpdateOnlyAdmin;
|
||||
private boolean showSettingsWhenNoLogin = true;
|
||||
private boolean customHTMLFiles;
|
||||
private String tessdataDir;
|
||||
@@ -890,9 +864,12 @@ public class ApplicationProperties {
|
||||
private Boolean enableAnalytics;
|
||||
private Boolean enablePosthog;
|
||||
private Boolean enableScarf;
|
||||
private Boolean enableDesktopInstallSlide = true;
|
||||
private Boolean enableDesktopInstallSlide;
|
||||
private Datasource datasource;
|
||||
private boolean disableSanitize;
|
||||
// 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();
|
||||
@@ -907,9 +884,8 @@ public class ApplicationProperties {
|
||||
private String frontendUrl; // Frontend URL for invite email links (e.g.
|
||||
|
||||
// 'https://app.example.com'). If not set, falls back to backendUrl.
|
||||
private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature
|
||||
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
|
||||
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
|
||||
private ServerCertificate serverCertificate = new ServerCertificate();
|
||||
|
||||
@Data
|
||||
public static class MobileScannerSettings {
|
||||
@@ -919,16 +895,6 @@ public class ApplicationProperties {
|
||||
private boolean stretchToFit = false; // Whether to stretch image to fill page
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ServerCertificate {
|
||||
private boolean enabled =
|
||||
true; // Enable server-side "Sign with Stirling-PDF" certificate
|
||||
private String organizationName = "Stirling PDF Inc";
|
||||
private int validity = 365; // Certificate validity in days
|
||||
private boolean regenerateOnStartup =
|
||||
false; // Generate a new certificate on each startup
|
||||
}
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.enableAnalytics != null && this.enableAnalytics;
|
||||
}
|
||||
@@ -1013,7 +979,7 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Sharing {
|
||||
private boolean enabled = false;
|
||||
private boolean linkEnabled = true;
|
||||
private boolean linkEnabled = false;
|
||||
private boolean emailEnabled = false;
|
||||
private int linkExpirationDays = 3;
|
||||
}
|
||||
@@ -1187,7 +1153,7 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Metrics {
|
||||
private boolean enabled = true;
|
||||
private boolean enabled;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -1239,7 +1205,7 @@ public class ApplicationProperties {
|
||||
private boolean enableInvites = false;
|
||||
private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days)
|
||||
private String host;
|
||||
private int port = 587;
|
||||
private int port;
|
||||
private String username;
|
||||
@ToString.Exclude private String password;
|
||||
private String from;
|
||||
@@ -1266,10 +1232,10 @@ public class ApplicationProperties {
|
||||
@ToString.Exclude private String botToken;
|
||||
private String botUsername;
|
||||
private String pipelineInboxFolder = "telegram";
|
||||
private Boolean customFolderSuffix = true;
|
||||
private Boolean enableAllowUserIDs = true;
|
||||
private Boolean customFolderSuffix = false;
|
||||
private Boolean enableAllowUserIDs = false;
|
||||
private List<Long> allowUserIDs = new ArrayList<>();
|
||||
private Boolean enableAllowChannelIDs = true;
|
||||
private Boolean enableAllowChannelIDs = false;
|
||||
private List<Long> allowChannelIDs = new ArrayList<>();
|
||||
private long processingTimeoutSeconds = 180;
|
||||
private long pollingIntervalMillis = 2000;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user