Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f651bc1014 | ||
|
|
5b9ef852ab | ||
|
|
672e81d286 | ||
|
|
8abe734f0b | ||
|
|
8a59c10f42 | ||
|
|
d62f2ad3ed | ||
|
|
f60a075443 | ||
|
|
c059e13423 | ||
|
|
575684ee4b | ||
|
|
294b616a63 | ||
|
|
f61121953e | ||
|
|
8645280ee5 | ||
|
|
bd50e2e59d | ||
|
|
77abaf5de5 | ||
|
|
503a81be50 | ||
|
|
67fc5c70b9 | ||
|
|
1e6514a471 | ||
|
|
c4874deb5d | ||
|
|
4ee47fdddd | ||
|
|
270af912d4 | ||
|
|
efd2ed754c | ||
|
|
6730ad7cbb | ||
|
|
8fb9fced35 | ||
|
|
ed56490f93 | ||
|
|
c892b985e2 | ||
|
|
95c39b4648 | ||
|
|
44f2d0d679 | ||
|
|
1a63aa045e | ||
|
|
1ae24e18b5 | ||
|
|
4764f1931b | ||
|
|
7f00441aab | ||
|
|
561d07b8b1 | ||
|
|
f39db01919 | ||
|
|
4ab7d3b3ae | ||
|
|
d8519a6517 | ||
|
|
34c9e9bdc5 | ||
|
|
4663a9a194 | ||
|
|
f89f7d99ba | ||
|
|
84e30cd008 | ||
|
|
69236a89c8 | ||
|
|
b66e12d15d | ||
|
|
3fe8adc5cb | ||
|
|
51f5345151 | ||
|
|
5541dd666c | ||
|
|
5605062153 | ||
|
|
86774d556e | ||
|
|
2dc5276e8b | ||
|
|
c58deb6b55 | ||
|
|
324a7cd25e | ||
|
|
d28ad89f64 | ||
|
|
d8c1ffa8ef | ||
|
|
b552feaf28 | ||
|
|
890cf16f74 | ||
|
|
493757bf41 | ||
|
|
99b9b60f50 | ||
|
|
85aa722f37 | ||
|
|
1c42ce7087 | ||
|
|
5bd62eeb5c | ||
|
|
87e5b84324 | ||
|
|
568c6da165 | ||
|
|
c818db1042 | ||
|
|
cd3a59f077 | ||
|
|
05e2ce45d4 | ||
|
|
94cf25b319 | ||
|
|
4149276b01 | ||
|
|
a26b15b1fe | ||
|
|
4e4918b91e | ||
|
|
b966e771a4 | ||
|
|
376e49a947 | ||
|
|
128697958b | ||
|
|
60c39c6f6e | ||
|
|
c3c5b08ce6 | ||
|
|
49f24fa7a9 | ||
|
|
12a00fe9c5 | ||
|
|
3c09b7f736 | ||
|
|
5116e6ec43 | ||
|
|
8daee7303d | ||
|
|
688c3e1e58 | ||
|
|
df5d0a9aa8 | ||
|
|
1e3da14081 | ||
|
|
276bbd635c | ||
|
|
8cd6377131 | ||
|
|
9890d6a7de | ||
|
|
5cfb77314f | ||
|
|
6ebd85defb | ||
|
|
c16dc1782d | ||
|
|
87464aca53 | ||
|
|
2ade011106 | ||
|
|
ac0ef7056e | ||
|
|
7cb0f928bc | ||
|
|
42978b9ad6 | ||
|
|
c5cdde0b58 |
@@ -22,7 +22,7 @@ runs:
|
||||
steps:
|
||||
- name: Generate a GitHub App Token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ inputs.app-id }}
|
||||
private-key: ${{ inputs.private-key }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.7.3
|
||||
pkgver=2.11.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.7.3
|
||||
pkgver=2.11.0
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -55,6 +55,25 @@ frontend: &frontend
|
||||
- scripts/type3_to_cff.py
|
||||
- scripts/update_type3_library.py
|
||||
|
||||
# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
|
||||
# job on changes to any of these.
|
||||
tauri: &tauri
|
||||
- frontend/src-tauri/**
|
||||
- frontend/src/desktop/**
|
||||
- frontend/tsconfig.desktop.json
|
||||
- frontend/package.json
|
||||
- frontend/package-lock.json
|
||||
- frontend/vite.config.ts
|
||||
- .github/workflows/tauri-build.yml
|
||||
|
||||
# Files that affect the AI engine (Python tool models, fixers, tests). Gate
|
||||
# the engine validation job on changes to engine sources or to the Java
|
||||
# tool surfaces it generates models from.
|
||||
engine: &engine
|
||||
- engine/**
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
- .github/workflows/ai-engine.yml
|
||||
|
||||
licenses-frontend: &licenses-frontend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
@@ -63,4 +82,23 @@ licenses-frontend: &licenses-frontend
|
||||
|
||||
licenses-backend: &licenses-backend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
- *build
|
||||
- *build
|
||||
|
||||
# Files that can affect premium / enterprise behaviour. Gate the enterprise
|
||||
# Playwright job on changes to any of these on PRs.
|
||||
proprietary: &proprietary
|
||||
- app/proprietary/**
|
||||
- frontend/src/proprietary/**
|
||||
- frontend/src/core/tests/enterprise/**
|
||||
- testing/compose/docker-compose-keycloak-oauth.yml
|
||||
- testing/compose/docker-compose-keycloak-saml.yml
|
||||
- testing/compose/keycloak-realm-oauth.json
|
||||
- testing/compose/keycloak-realm-saml.json
|
||||
- testing/compose/start-oauth-test.sh
|
||||
- testing/compose/start-saml-test.sh
|
||||
- testing/compose/validate-oauth-test.sh
|
||||
- testing/compose/validate-saml-test.sh
|
||||
- configs/settings.yml.template
|
||||
- build.gradle
|
||||
- app/proprietary/build.gradle
|
||||
- .github/workflows/build-enterprise.yml
|
||||
@@ -13,6 +13,8 @@ updates:
|
||||
- "/app/proprietary"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
@@ -25,12 +27,16 @@ updates:
|
||||
- "/docker/engine"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: npm
|
||||
@@ -39,7 +45,84 @@ updates:
|
||||
- /frontend
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
rebase-strategy: "auto"
|
||||
groups:
|
||||
embedpdf:
|
||||
patterns:
|
||||
- "@embedpdf/*"
|
||||
mantine:
|
||||
patterns:
|
||||
- "@mantine/*"
|
||||
- "postcss-preset-mantine"
|
||||
mui:
|
||||
patterns:
|
||||
- "@mui/*"
|
||||
tauri-js:
|
||||
patterns:
|
||||
- "@tauri-apps/*"
|
||||
emotion:
|
||||
patterns:
|
||||
- "@emotion/*"
|
||||
react:
|
||||
patterns:
|
||||
- "react"
|
||||
- "react-dom"
|
||||
- "@types/react"
|
||||
- "@types/react-dom"
|
||||
typescript-eslint:
|
||||
patterns:
|
||||
- "@typescript-eslint/*"
|
||||
- "typescript-eslint"
|
||||
eslint:
|
||||
patterns:
|
||||
- "eslint"
|
||||
- "@eslint/*"
|
||||
vite:
|
||||
patterns:
|
||||
- "vite"
|
||||
- "vite-*"
|
||||
- "@vitejs/*"
|
||||
vitest:
|
||||
patterns:
|
||||
- "vitest"
|
||||
- "@vitest/*"
|
||||
testing-library:
|
||||
patterns:
|
||||
- "@testing-library/*"
|
||||
i18next:
|
||||
patterns:
|
||||
- "i18next"
|
||||
- "i18next-*"
|
||||
- "react-i18next"
|
||||
iconify:
|
||||
patterns:
|
||||
- "@iconify/*"
|
||||
- "@iconify-json/*"
|
||||
stripe:
|
||||
patterns:
|
||||
- "@stripe/*"
|
||||
posthog:
|
||||
patterns:
|
||||
- "@posthog/*"
|
||||
- "posthog-js"
|
||||
supabase:
|
||||
patterns:
|
||||
- "@supabase/*"
|
||||
dnd-kit:
|
||||
patterns:
|
||||
- "@dnd-kit/*"
|
||||
tailwind:
|
||||
patterns:
|
||||
- "tailwindcss"
|
||||
- "@tailwindcss/*"
|
||||
postcss:
|
||||
patterns:
|
||||
- "postcss"
|
||||
- "postcss-*"
|
||||
exclude-patterns:
|
||||
- "postcss-preset-mantine"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directories:
|
||||
@@ -48,10 +131,32 @@ updates:
|
||||
- /frontend/src-tauri/provisioner
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
rebase-strategy: "auto"
|
||||
groups:
|
||||
tauri:
|
||||
patterns:
|
||||
- "tauri"
|
||||
- "tauri-build"
|
||||
- "tauri-plugin-*"
|
||||
serde:
|
||||
patterns:
|
||||
- "serde"
|
||||
- "serde_*"
|
||||
tracing:
|
||||
patterns:
|
||||
- "tracing"
|
||||
- "tracing-*"
|
||||
tokio:
|
||||
patterns:
|
||||
- "tokio"
|
||||
- "tokio-*"
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /testing/cucumber
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
rebase-strategy: "auto"
|
||||
|
||||
@@ -72,6 +72,12 @@ labels:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src-tauri/.*'
|
||||
|
||||
- label: 'engine'
|
||||
files:
|
||||
- 'engine/**'
|
||||
- 'engine/.*'
|
||||
- 'engine/**/.*'
|
||||
|
||||
- label: 'Java'
|
||||
files:
|
||||
- 'app/common/src/main/java/.*.java'
|
||||
|
||||
+15
-2
@@ -1,4 +1,11 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
# engine: Python AI engine - not currently live / not yet shipped to users.
|
||||
# Remove this entry once the engine is released.
|
||||
- engine
|
||||
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels:
|
||||
@@ -6,16 +13,22 @@ changelog:
|
||||
|
||||
- title: Bug Fixes
|
||||
labels:
|
||||
- Bugfix
|
||||
- Bug
|
||||
|
||||
- title: Enhancements
|
||||
labels:
|
||||
- enhancement
|
||||
- Java
|
||||
- Front End
|
||||
- Back End
|
||||
- Tauri
|
||||
|
||||
- title: Minor Enhancements
|
||||
labels:
|
||||
- Java
|
||||
- Front End
|
||||
- chore
|
||||
- style
|
||||
- refactor
|
||||
|
||||
- title: Docker Updates
|
||||
labels:
|
||||
|
||||
@@ -35,13 +35,13 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Resolve PR info
|
||||
id: resolve
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
|
||||
- name: Add deployment started comment
|
||||
id: deployment-started
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
|
||||
- name: Build and push V2 image
|
||||
if: steps.check-image.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
|
||||
- name: Post V2 deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -376,7 +376,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clean up V2 deployment comments
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
- name: Get PR data
|
||||
id: get-pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.eventName === 'workflow_dispatch'
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Add 'in_progress' reaction to comment
|
||||
if: github.event_name == 'issue_comment'
|
||||
id: add-eyes-reaction
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -233,16 +233,16 @@ jobs:
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push PR-specific image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
|
||||
- name: Build and push engine image
|
||||
if: needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: ./engine
|
||||
file: ./engine/Dockerfile
|
||||
@@ -383,7 +383,7 @@ jobs:
|
||||
|
||||
- name: Add success reaction to comment
|
||||
if: success() && github.event_name == 'issue_comment'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -418,7 +418,7 @@ jobs:
|
||||
|
||||
- name: Add failure reaction to comment
|
||||
if: failure() && github.event_name == 'issue_comment'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -438,7 +438,7 @@ jobs:
|
||||
|
||||
- name: Post deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -474,7 +474,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -489,7 +489,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Apply label commands
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Remove 'pr-deployed' label if present
|
||||
id: remove-label-comment
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
name: AI Engine CI
|
||||
|
||||
# Validates the Python AI engine: regenerates tool models, runs fixers,
|
||||
# lint, type-check, and tests. Called from build.yml on PRs and merge_group;
|
||||
# also runs directly on push to main as a post-merge safety net.
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -16,7 +19,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -24,7 +27,7 @@ jobs:
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
@@ -35,7 +38,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -68,7 +71,7 @@ jobs:
|
||||
- name: Comment on fixer failures
|
||||
if: steps.fixer_changes.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- engine-check -->';
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Post comment on PR if needed
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
- name: Publish stirling-pdf-desktop to AUR
|
||||
if: ${{ github.event_name == 'release' || inputs.dry_run == false }}
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v4.1.2
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v4.1.3
|
||||
with:
|
||||
pkgname: stirling-pdf-desktop
|
||||
pkgbuild: .github/aur/stirling-pdf-desktop/PKGBUILD
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
name: Backend build, format check, and coverage
|
||||
|
||||
# Reusable workflow called from build.yml. Runs the full backend build matrix
|
||||
# (JDK 21/25 × spring-security on/off), Spotless formatting check, JUnit, and
|
||||
# posts Jacoco coverage to PRs.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
jdk-version: [21, 25]
|
||||
spring-security: [true, false]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK ${{ matrix.jdk-version }}
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Check Java formatting (Spotless)
|
||||
if: matrix.jdk-version == 25 && matrix.spring-security == false
|
||||
id: spotless-check
|
||||
run: task backend:format:check
|
||||
continue-on-error: true
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Comment on Java formatting failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.spotless-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- java-formatting-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Java Formatting Check Failed',
|
||||
'',
|
||||
'Your code has formatting issues. Run the following command to fix them:',
|
||||
'',
|
||||
'```bash',
|
||||
'task backend:format',
|
||||
'```',
|
||||
'',
|
||||
'Then commit and push the changes.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if Java formatting issues found
|
||||
if: steps.spotless-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Java Formatting Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Your code has formatting issues."
|
||||
echo "Run the following command to fix them:"
|
||||
echo ""
|
||||
echo " task backend:format"
|
||||
echo ""
|
||||
echo "Then commit and push the changes."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
run: task backend:build:ci
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
|
||||
|
||||
- name: Check Test Reports Exist
|
||||
if: always()
|
||||
run: |
|
||||
declare -a dirs=(
|
||||
"app/core/build/reports/tests/"
|
||||
"app/core/build/test-results/"
|
||||
"app/common/build/reports/tests/"
|
||||
"app/common/build/test-results/"
|
||||
"app/proprietary/build/reports/tests/"
|
||||
"app/proprietary/build/test-results/"
|
||||
)
|
||||
for dir in "${dirs[@]}"; do
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "Missing $dir"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
app/**/build/reports/jacoco/test
|
||||
app/**/build/reports/tests/
|
||||
app/**/build/test-results/
|
||||
app/**/build/reports/problems/
|
||||
build/reports/problems/
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Add coverage to PR with spring security ${{ matrix.spring-security }} and JDK ${{ matrix.jdk-version }}
|
||||
# The action only supports the pull_request event (it posts a PR comment),
|
||||
# so skip it for merge_group runs and workflow_dispatch.
|
||||
if: github.event_name == 'pull_request'
|
||||
id: jacoco
|
||||
uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2
|
||||
with:
|
||||
paths: |
|
||||
${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
min-coverage-overall: 10
|
||||
min-coverage-changed-files: 0
|
||||
comment-type: summary
|
||||
@@ -0,0 +1,272 @@
|
||||
name: Enterprise E2E (Playwright)
|
||||
|
||||
# Enterprise Playwright suite — exercises premium-key gated features (audit,
|
||||
# teams, analytics) plus full OAuth + SAML logins via the Keycloak compose
|
||||
# stacks under testing/compose. Slow and secret-gated, so it runs in three
|
||||
# situations:
|
||||
#
|
||||
# - PRs that touch proprietary / premium / SSO compose / enterprise tests
|
||||
# (driven by build.yml via workflow_call, path-filtered against
|
||||
# .github/config/.files.yaml `proprietary`),
|
||||
# - every push to main (post-merge safety net),
|
||||
# - on a nightly cron schedule (catches Keycloak image drift, license
|
||||
# expiry, upstream proprietary changes),
|
||||
# - manual workflow_dispatch.
|
||||
#
|
||||
# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot).
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# No `concurrency:` block here on purpose. When this workflow is called via
|
||||
# workflow_call from build.yml, ${{ github.workflow }}/event_name/pr_number
|
||||
# resolve to the *caller's* values, producing the same group key as build.yml
|
||||
# and causing the workflow_call instantiation to self-cancel — the job
|
||||
# silently fails to spawn while `${{ needs.X.result }}` still reports
|
||||
# `failure`. Standalone runs (push-to-main, nightly cron, dispatch) don't
|
||||
# overlap often enough to need explicit concurrency control.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
playwright-e2e-enterprise:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
|
||||
PREMIUM_ENABLED: "true"
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (needed for playwright's vite preview webServer)
|
||||
# Enterprise tests target :8080 (Spring Boot's bundled frontend), but
|
||||
# playwright's webServer config still launches `vite preview --port 5173`
|
||||
# before any test run, which needs dist/ to exist. VITE_BUILD_FOR_PREVIEW
|
||||
# forces absolute asset paths so vite preview can serve deep SPA routes.
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
|
||||
- name: Resolve kubernetes.docker.internal to localhost
|
||||
# The compose stacks set KC_HOSTNAME=kubernetes.docker.internal so
|
||||
# Keycloak issues redirect URIs against that host. Docker Desktop
|
||||
# auto-resolves it; GHA runners don't. Map it to 127.0.0.1 so the
|
||||
# browser-driven OAuth flow lands back on Stirling-PDF correctly.
|
||||
run: |
|
||||
echo "127.0.0.1 kubernetes.docker.internal" | sudo tee -a /etc/hosts
|
||||
|
||||
# Helper function used by all phases — boots `:stirling-pdf:bootRun`
|
||||
# with the React frontend baked in (-PbuildWithFrontend=true) so the
|
||||
# SPA serves on :8080 and OAuth/SAML callbacks land on the same host
|
||||
# that the browser is interacting with.
|
||||
- name: Define helpers
|
||||
run: |
|
||||
{
|
||||
echo 'wait_for_backend() {'
|
||||
echo ' start=$SECONDS'
|
||||
echo ' for i in $(seq 1 300); do'
|
||||
echo ' if curl -fsS http://localhost:8080/api/v1/info/status >/dev/null 2>&1; then'
|
||||
echo ' echo "Backend up after $((SECONDS - start))s"; return 0'
|
||||
echo ' fi; sleep 2'
|
||||
echo ' done'
|
||||
echo ' tail -200 /tmp/backend.log || true; return 1'
|
||||
echo '}'
|
||||
echo 'stop_backend() {'
|
||||
echo ' if [ -f /tmp/backend.pid ]; then'
|
||||
echo ' kill "$(cat /tmp/backend.pid)" 2>/dev/null || true'
|
||||
echo ' rm -f /tmp/backend.pid'
|
||||
echo ' fi'
|
||||
echo ' pkill -f "gradlew :stirling-pdf:bootRun" 2>/dev/null || true'
|
||||
echo ' for i in $(seq 1 30); do'
|
||||
echo ' curl -fsS http://localhost:8080/api/v1/info/status >/dev/null 2>&1 || return 0'
|
||||
echo ' sleep 1'
|
||||
echo ' done'
|
||||
echo '}'
|
||||
} > /tmp/helpers.sh
|
||||
chmod +x /tmp/helpers.sh
|
||||
|
||||
# ───────── OAuth round-trip ─────────
|
||||
- name: Bring up Keycloak (OAuth realm)
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f docker-compose-keycloak-oauth.yml up -d --no-deps keycloak-oauth-db keycloak-oauth
|
||||
- name: Wait for Keycloak (OAuth) ready
|
||||
working-directory: testing/compose
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
bash validate-oauth-test.sh 2>/dev/null && exit 0 || true
|
||||
# validate script also pings stirling on :8080 — accept just the
|
||||
# keycloak realm as our gate here, stirling boots in the next step
|
||||
curl -fsS http://localhost:9080/realms/stirling-oauth >/dev/null 2>&1 && exit 0
|
||||
sleep 5
|
||||
done
|
||||
docker compose -f docker-compose-keycloak-oauth.yml logs --tail=200 keycloak-oauth
|
||||
exit 1
|
||||
- name: Boot Stirling-PDF (frontend baked in, OAuth env)
|
||||
env:
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_LOGINMETHOD: "all"
|
||||
SECURITY_OAUTH2_ENABLED: "true"
|
||||
SECURITY_OAUTH2_AUTOCREATEUSER: "true"
|
||||
# Keycloak issues redirect URIs against KC_HOSTNAME, which the
|
||||
# compose default sets to kubernetes.docker.internal. Match here
|
||||
# (resolves to localhost via /etc/hosts mapping above).
|
||||
SECURITY_OAUTH2_CLIENT_KEYCLOAK_ISSUER: "http://kubernetes.docker.internal:9080/realms/stirling-oauth"
|
||||
SECURITY_OAUTH2_CLIENT_KEYCLOAK_CLIENTID: "stirling-pdf-client"
|
||||
SECURITY_OAUTH2_CLIENT_KEYCLOAK_CLIENTSECRET: "test-client-secret-change-in-production"
|
||||
SECURITY_OAUTH2_CLIENT_KEYCLOAK_USEASUSERNAME: "email"
|
||||
SECURITY_OAUTH2_CLIENT_KEYCLOAK_SCOPES: "openid,profile,email"
|
||||
run: |
|
||||
source /tmp/helpers.sh
|
||||
nohup ./gradlew :stirling-pdf:bootRun -PbuildWithFrontend=true > /tmp/backend.log 2>&1 &
|
||||
echo $! > /tmp/backend.pid
|
||||
wait_for_backend
|
||||
- name: Run enterprise OAuth Playwright tests
|
||||
id: oauth-tests
|
||||
run: task e2e:enterprise -- --grep "OAuth"
|
||||
- name: Stop backend + tear down OAuth Keycloak
|
||||
if: always()
|
||||
run: |
|
||||
source /tmp/helpers.sh
|
||||
stop_backend
|
||||
(cd testing/compose && docker compose -f docker-compose-keycloak-oauth.yml down -v)
|
||||
|
||||
# ───────── SAML round-trip ─────────
|
||||
- name: Bring up Keycloak (SAML realm)
|
||||
working-directory: testing/compose
|
||||
run: docker compose -f docker-compose-keycloak-saml.yml up -d --no-deps keycloak-saml-db keycloak-saml
|
||||
- name: Wait for Keycloak (SAML) ready
|
||||
working-directory: testing/compose
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
curl -fsS http://localhost:9080/realms/stirling-saml >/dev/null 2>&1 && exit 0
|
||||
sleep 5
|
||||
done
|
||||
docker compose -f docker-compose-keycloak-saml.yml logs --tail=200 keycloak-saml
|
||||
exit 1
|
||||
- name: Generate SAML SP certs + fetch Keycloak IdP cert
|
||||
# The .pem/.crt/.key files are gitignored (test-only certs); the
|
||||
# docker-based start-saml-test.sh generates them at runtime, so do
|
||||
# the same in CI before bootRun reads them.
|
||||
working-directory: testing/compose
|
||||
run: |
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout saml-private-key.key \
|
||||
-out saml-public-cert.crt \
|
||||
-days 3650 -nodes \
|
||||
-subj "/CN=stirling-pdf-saml-sp" >/dev/null 2>&1
|
||||
# Fetch Keycloak's SAML signing cert from the realm descriptor
|
||||
CERT_BODY=$(curl -sf http://localhost:9080/realms/stirling-saml/protocol/saml/descriptor \
|
||||
| awk 'BEGIN{RS="<[^>]*X509Certificate>|</[^>]*X509Certificate>"} NR==2{gsub(/[[:space:]]+/,""); print; exit}')
|
||||
{
|
||||
echo "-----BEGIN CERTIFICATE-----"
|
||||
echo "$CERT_BODY"
|
||||
echo "-----END CERTIFICATE-----"
|
||||
} > keycloak-saml-cert.pem
|
||||
test -s saml-private-key.key
|
||||
test -s saml-public-cert.crt
|
||||
test -s keycloak-saml-cert.pem
|
||||
echo "✓ SAML certs prepared"
|
||||
- name: Boot Stirling-PDF (frontend baked in, SAML env)
|
||||
env:
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_LOGINMETHOD: "all"
|
||||
SECURITY_SAML2_ENABLED: "true"
|
||||
SECURITY_SAML2_AUTOCREATEUSER: "true"
|
||||
SECURITY_SAML2_PROVIDER: "keycloak"
|
||||
SECURITY_SAML2_REGISTRATIONID: "keycloak"
|
||||
SECURITY_SAML2_IDP_ISSUER: "http://localhost:9080/realms/stirling-saml"
|
||||
SECURITY_SAML2_IDP_ENTITYID: "http://localhost:9080/realms/stirling-saml"
|
||||
SECURITY_SAML2_IDP_METADATAURI: "http://localhost:9080/realms/stirling-saml/protocol/saml/descriptor"
|
||||
SECURITY_SAML2_IDPSINGLELOGINURL: "http://localhost:9080/realms/stirling-saml/protocol/saml"
|
||||
SECURITY_SAML2_IDPSINGLELOGOUTURL: "http://localhost:9080/realms/stirling-saml/protocol/saml"
|
||||
SECURITY_SAML2_IDP_CERT: "${{ github.workspace }}/testing/compose/keycloak-saml-cert.pem"
|
||||
SECURITY_SAML2_PRIVATEKEY: "${{ github.workspace }}/testing/compose/saml-private-key.key"
|
||||
SECURITY_SAML2_SP_CERT: "${{ github.workspace }}/testing/compose/saml-public-cert.crt"
|
||||
# Realm registers the SP entity as the metadata URL — see
|
||||
# keycloak-realm-saml.json `clientId`. Match it here so Keycloak
|
||||
# accepts the AuthnRequest issuer.
|
||||
SECURITY_SAML2_SP_ENTITYID: "http://localhost:8080/saml2/service-provider-metadata/keycloak"
|
||||
SECURITY_SAML2_SP_ACS: "http://localhost:8080/login/saml2/sso/keycloak"
|
||||
SECURITY_SAML2_SP_SLS: "http://localhost:8080/logout/saml2/slo"
|
||||
run: |
|
||||
source /tmp/helpers.sh
|
||||
nohup ./gradlew :stirling-pdf:bootRun -PbuildWithFrontend=true > /tmp/backend.log 2>&1 &
|
||||
echo $! > /tmp/backend.pid
|
||||
wait_for_backend
|
||||
- name: Run enterprise SAML Playwright tests
|
||||
id: saml-tests
|
||||
run: task e2e:enterprise -- --grep "SAML"
|
||||
- name: Stop backend + tear down SAML Keycloak
|
||||
if: always()
|
||||
run: |
|
||||
source /tmp/helpers.sh
|
||||
stop_backend
|
||||
(cd testing/compose && docker compose -f docker-compose-keycloak-saml.yml down -v)
|
||||
|
||||
# ───────── License-gated feature tests (no IdP needed) ─────────
|
||||
- name: Wipe DB so InitialSecuritySetup re-runs with admin/adminadmin
|
||||
# Earlier phases (OAuth, SAML) create the default admin/stirling user.
|
||||
# InitialSecuritySetup only honours SECURITY_INITIALLOGIN_* when the
|
||||
# admin user doesn't already exist, so the persisted DB has to be
|
||||
# cleared between phases for the feature env vars to take effect.
|
||||
run: |
|
||||
rm -f app/core/configs/stirling-pdf-DB*.mv.db
|
||||
rm -rf app/core/configs/backup
|
||||
- name: Boot Stirling-PDF (frontend baked in, premium only)
|
||||
env:
|
||||
SECURITY_INITIALLOGIN_USERNAME: admin
|
||||
SECURITY_INITIALLOGIN_PASSWORD: adminadmin
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_LOGINMETHOD: "all"
|
||||
run: |
|
||||
source /tmp/helpers.sh
|
||||
nohup ./gradlew :stirling-pdf:bootRun -PbuildWithFrontend=true > /tmp/backend.log 2>&1 &
|
||||
echo $! > /tmp/backend.pid
|
||||
wait_for_backend
|
||||
- name: Run enterprise feature Playwright tests
|
||||
id: feature-tests
|
||||
run: task e2e:enterprise -- --grep "Enterprise license"
|
||||
- name: Print backend log on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "::group::Enterprise backend log"
|
||||
tail -500 /tmp/backend.log || true
|
||||
echo "::endgroup::"
|
||||
- name: Stop backend (final)
|
||||
if: always()
|
||||
run: |
|
||||
source /tmp/helpers.sh
|
||||
stop_backend
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-enterprise-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
+148
-579
@@ -1,8 +1,18 @@
|
||||
name: Build and Test Workflow
|
||||
|
||||
# Top-level PR / merge-queue gate. Detects which paths changed and dispatches
|
||||
# to the dedicated reusable workflows under .github/workflows/. Each child
|
||||
# workflow keeps its own setup/teardown so this file stays a routing layer.
|
||||
#
|
||||
# The final `all-checks-passed` job is the single status check that branch
|
||||
# protection should require — it succeeds only if every required upstream
|
||||
# job either succeeded or was legitimately skipped by its path filter.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
merge_group:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
@@ -31,9 +41,12 @@ jobs:
|
||||
openapi: ${{ steps.changes.outputs.openapi }}
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
docker-base: ${{ steps.changes.outputs.docker-base }}
|
||||
tauri: ${{ steps.changes.outputs.tauri }}
|
||||
engine: ${{ steps.changes.outputs.engine }}
|
||||
proprietary: ${{ steps.changes.outputs.proprietary }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -46,618 +59,174 @@ jobs:
|
||||
filters: .github/config/.files.yaml
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
pull-requests: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
jdk-version: [21, 25]
|
||||
spring-security: [true, false]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK ${{ matrix.jdk-version }}
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Check Java formatting (Spotless)
|
||||
if: matrix.jdk-version == 25 && matrix.spring-security == false
|
||||
id: spotless-check
|
||||
run: task backend:format:check
|
||||
continue-on-error: true
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Comment on Java formatting failure
|
||||
if: steps.spotless-check.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- java-formatting-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Java Formatting Check Failed',
|
||||
'',
|
||||
'Your code has formatting issues. Run the following command to fix them:',
|
||||
'',
|
||||
'```bash',
|
||||
'task backend:format',
|
||||
'```',
|
||||
'',
|
||||
'Then commit and push the changes.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if Java formatting issues found
|
||||
if: steps.spotless-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Java Formatting Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Your code has formatting issues."
|
||||
echo "Run the following command to fix them:"
|
||||
echo ""
|
||||
echo " task backend:format"
|
||||
echo ""
|
||||
echo "Then commit and push the changes."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
run: task backend:build:ci
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
|
||||
|
||||
- name: Check Test Reports Exist
|
||||
if: always()
|
||||
run: |
|
||||
declare -a dirs=(
|
||||
"app/core/build/reports/tests/"
|
||||
"app/core/build/test-results/"
|
||||
"app/common/build/reports/tests/"
|
||||
"app/common/build/test-results/"
|
||||
"app/proprietary/build/reports/tests/"
|
||||
"app/proprietary/build/test-results/"
|
||||
)
|
||||
for dir in "${dirs[@]}"; do
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "Missing $dir"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
app/**/build/reports/jacoco/test
|
||||
app/**/build/reports/tests/
|
||||
app/**/build/test-results/
|
||||
app/**/build/reports/problems/
|
||||
build/reports/problems/
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Add coverage to PR with spring security ${{ matrix.spring-security }} and JDK ${{ matrix.jdk-version }}
|
||||
id: jacoco
|
||||
uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2
|
||||
with:
|
||||
paths: |
|
||||
${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
min-coverage-overall: 10
|
||||
min-coverage-changed-files: 0
|
||||
comment-type: summary
|
||||
uses: ./.github/workflows/backend-build.yml
|
||||
secrets: inherit
|
||||
|
||||
check-generateOpenApiDocs:
|
||||
if: needs.files-changed.outputs.openapi == 'true'
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Generate OpenAPI documentation
|
||||
run: task backend:swagger
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/check-openapi.yml
|
||||
secrets: inherit
|
||||
|
||||
frontend-validation:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Quality-check frontend
|
||||
id: frontend-check
|
||||
run: task frontend:check:all
|
||||
continue-on-error: true
|
||||
- name: Comment on frontend check failure
|
||||
if: steps.frontend-check.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- frontend-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Frontend Check Failed',
|
||||
'',
|
||||
'There are issues with your frontend code that will need to be fixed before they can be merged in.',
|
||||
'',
|
||||
'Run `task frontend:fix` to auto-fix what can be fixed automatically, then run `task frontend:check:all` to see what still needs fixing manually.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
- name: Fail if frontend check failed
|
||||
if: steps.frontend-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Frontend Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "There are issues with your frontend code that"
|
||||
echo "will need to be fixed before they can be merged in."
|
||||
echo ""
|
||||
echo "Run 'task frontend:fix' to auto-fix what can be"
|
||||
echo "fixed automatically, then run 'task frontend:check:all'"
|
||||
echo "to see what still needs fixing manually."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/dist/
|
||||
retention-days: 3
|
||||
uses: ./.github/workflows/frontend-validation.yml
|
||||
secrets: inherit
|
||||
|
||||
playwright-e2e:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task frontend:test:e2e:install -- chromium
|
||||
- name: Run E2E tests (chromium)
|
||||
run: task frontend:test:e2e -- --project=chromium
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-report-pr-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/e2e-stubbed.yml
|
||||
secrets: inherit
|
||||
|
||||
playwright-e2e-live:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/e2e-live.yml
|
||||
secrets: inherit
|
||||
|
||||
playwright-e2e-enterprise:
|
||||
if: needs.files-changed.outputs.proprietary == 'true'
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/build-enterprise.yml
|
||||
secrets: inherit
|
||||
|
||||
check-licence:
|
||||
if: needs.files-changed.outputs.build == 'true'
|
||||
needs: [files-changed, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Check licenses for compatibility
|
||||
run: task backend:licenses:check
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: FAILED - check the licenses for compatibility
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
retention-days: 3
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/check-licence.yml
|
||||
secrets: inherit
|
||||
|
||||
docker-compose-tests:
|
||||
if: needs.files-changed.outputs.project == 'true'
|
||||
needs: files-changed
|
||||
# if: github.event_name == 'push' && github.ref == 'refs/heads/main' ||
|
||||
# (github.event_name == 'pull_request' &&
|
||||
# contains(github.event.pull_request.labels.*.name, 'licenses') == false &&
|
||||
# (
|
||||
# contains(github.event.pull_request.labels.*.name, 'Front End') ||
|
||||
# contains(github.event.pull_request.labels.*.name, 'Java') ||
|
||||
# contains(github.event.pull_request.labels.*.name, 'Back End') ||
|
||||
# contains(github.event.pull_request.labels.*.name, 'Security') ||
|
||||
# contains(github.event.pull_request.labels.*.name, 'API') ||
|
||||
# contains(github.event.pull_request.labels.*.name, 'Docker') ||
|
||||
# contains(github.event.pull_request.labels.*.name, 'Test')
|
||||
# )
|
||||
# )
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
checks: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Pip requirements
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Run Docker Compose Tests
|
||||
run: |
|
||||
chmod +x ./testing/test_webpages.sh
|
||||
chmod +x ./testing/test.sh
|
||||
chmod +x ./testing/test_disabledEndpoints.sh
|
||||
./testing/test.sh
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DOCKER_BASE_CHANGED: ${{ needs.files-changed.outputs.docker-base }}
|
||||
|
||||
- name: Upload Cucumber Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: testing/cucumber/report.html
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-compose-test-reports
|
||||
path: testing/reports/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Cucumber Test Report
|
||||
if: always()
|
||||
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
|
||||
with:
|
||||
name: Cucumber Tests
|
||||
path: testing/cucumber/junit/*.xml
|
||||
reporter: java-junit
|
||||
fail-on-error: false
|
||||
uses: ./.github/workflows/docker-compose-tests.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
docker-base-changed: ${{ needs.files-changed.outputs.docker-base }}
|
||||
|
||||
test-build-docker-images:
|
||||
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
|
||||
needs: [files-changed, build, check-generateOpenApiDocs, check-licence]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
uses: ./.github/workflows/test-build-docker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
docker-base-changed: ${{ needs.files-changed.outputs.docker-base }}
|
||||
|
||||
tauri-build:
|
||||
if: needs.files-changed.outputs.tauri == 'true'
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/tauri-build.yml
|
||||
secrets: inherit
|
||||
|
||||
ai-engine:
|
||||
if: needs.files-changed.outputs.engine == 'true'
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/ai-engine.yml
|
||||
secrets: inherit
|
||||
|
||||
pre-commit:
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/pre_commit.yml
|
||||
secrets: inherit
|
||||
|
||||
dependency-review:
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/dependency-review.yml
|
||||
secrets: inherit
|
||||
|
||||
# Single status check that branch protection should mark as required.
|
||||
# Succeeds when every upstream job is either `success` or `skipped` (path-
|
||||
# gated jobs that didn't apply this run). Any `failure` or `cancelled`
|
||||
# result fails the gate. `if: always()` ensures the gate evaluates even
|
||||
# when an upstream job fails.
|
||||
all-checks-passed:
|
||||
name: All checks passed
|
||||
if: always()
|
||||
needs:
|
||||
- files-changed
|
||||
- build
|
||||
- check-generateOpenApiDocs
|
||||
- frontend-validation
|
||||
- playwright-e2e
|
||||
- playwright-e2e-live
|
||||
- playwright-e2e-enterprise
|
||||
- check-licence
|
||||
- docker-compose-tests
|
||||
- test-build-docker-images
|
||||
- tauri-build
|
||||
- ai-engine
|
||||
- pre-commit
|
||||
- dependency-review
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- docker-rev: docker/embedded/Dockerfile
|
||||
artifact-suffix: Dockerfile
|
||||
cache-scope: stirling-pdf-latest
|
||||
- docker-rev: docker/embedded/Dockerfile.ultra-lite
|
||||
artifact-suffix: Dockerfile.ultra-lite
|
||||
cache-scope: stirling-pdf-ultra-lite
|
||||
- docker-rev: docker/embedded/Dockerfile.fat
|
||||
artifact-suffix: Dockerfile.fat
|
||||
cache-scope: stirling-pdf-fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Free disk space on runner
|
||||
run: |
|
||||
echo "Disk space before cleanup:" && df -h
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost
|
||||
docker system prune -af || true
|
||||
echo "Disk space after cleanup:" && df -h
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Build application
|
||||
run: task backend:build
|
||||
- name: Verify every required job passed (or was legitimately skipped)
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build base image locally (PR base change only)
|
||||
if: github.event_name == 'pull_request' && needs.files-changed.outputs.docker-base == 'true'
|
||||
RESULTS: |
|
||||
files-changed=${{ needs.files-changed.result }}
|
||||
build=${{ needs.build.result }}
|
||||
check-generateOpenApiDocs=${{ needs.check-generateOpenApiDocs.result }}
|
||||
frontend-validation=${{ needs.frontend-validation.result }}
|
||||
playwright-e2e=${{ needs.playwright-e2e.result }}
|
||||
playwright-e2e-live=${{ needs.playwright-e2e-live.result }}
|
||||
playwright-e2e-enterprise=${{ needs.playwright-e2e-enterprise.result }}
|
||||
check-licence=${{ needs.check-licence.result }}
|
||||
docker-compose-tests=${{ needs.docker-compose-tests.result }}
|
||||
test-build-docker-images=${{ needs.test-build-docker-images.result }}
|
||||
tauri-build=${{ needs.tauri-build.result }}
|
||||
ai-engine=${{ needs.ai-engine.result }}
|
||||
pre-commit=${{ needs.pre-commit.result }}
|
||||
dependency-review=${{ needs.dependency-review.result }}
|
||||
run: |
|
||||
docker build -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base
|
||||
|
||||
- name: Set base image and platform for this build
|
||||
id: build-params
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "pull_request" ] && [ "${{ needs.files-changed.outputs.docker-base }}" == "true" ]; then
|
||||
echo "base_image=stirling-pdf-base:pr-test" >> $GITHUB_OUTPUT
|
||||
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> $GITHUB_OUTPUT
|
||||
echo "platforms=linux/amd64,linux/arm64/v8" >> $GITHUB_OUTPUT
|
||||
ok=true
|
||||
while IFS='=' read -r name result; do
|
||||
[ -z "$name" ] && continue
|
||||
case "$result" in
|
||||
success|skipped) printf ' %-30s %s\n' "$name" "$result" ;;
|
||||
*) printf '✗ %-30s %s\n' "$name" "$result"; ok=false ;;
|
||||
esac
|
||||
done <<< "$RESULTS"
|
||||
if [ "$ok" != "true" ]; then
|
||||
echo ""
|
||||
echo "One or more required checks failed or were cancelled."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
cache-from: type=gha,scope=${{ matrix.cache-scope }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
|
||||
platforms: ${{ steps.build-params.outputs.platforms }}
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ steps.build-params.outputs.base_image }}
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Upload Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: reports-docker-${{ matrix.artifact-suffix }}
|
||||
path: |
|
||||
build/reports/tests/
|
||||
build/test-results/
|
||||
build/reports/problems/
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
echo ""
|
||||
echo "All required checks passed."
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: License compatibility check
|
||||
|
||||
# Reusable workflow called from build.yml when build.gradle / module gradle
|
||||
# files change. Verifies all transitive dependencies use a permitted license.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-licence:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Check licenses for compatibility
|
||||
run: task backend:licenses:check
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: FAILED - check the licenses for compatibility
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
retention-days: 3
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Generate OpenAPI documentation
|
||||
|
||||
# Reusable workflow called from build.yml when backend Java sources change.
|
||||
# Generates SwaggerDoc.json so we know the doc still builds against the
|
||||
# current code.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-generate-openapi-docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Generate OpenAPI documentation
|
||||
run: task backend:swagger
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
- name: Get PR data
|
||||
id: get-pr-data
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
|
||||
- name: Determine reference file
|
||||
id: determine-file
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
|
||||
- name: Post comment on PR
|
||||
if: env.SCRIPT_OUTPUT != ''
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Dependency Review Action
|
||||
#
|
||||
# This Action will scan dependency manifest files that change as part of a Pull Request,
|
||||
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
|
||||
# Once installed, if the workflow run is marked as required,
|
||||
# PRs introducing known-vulnerable packages will be blocked from merging.
|
||||
#
|
||||
# Source repository: https://github.com/actions/dependency-review-action
|
||||
name: "Dependency Review"
|
||||
on: [pull_request]
|
||||
name: Dependency Review
|
||||
|
||||
# Reusable workflow called from build.yml. Scans dependency manifest files
|
||||
# changing in a PR for known-vulnerable packages, using the rules in
|
||||
# .github/config/dependency-review-config.yml.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -17,13 +14,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||
with:
|
||||
config-file: "./.github/config/dependency-review-config.yml"
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Get commit hashes for frontend and backend
|
||||
id: commit-hashes
|
||||
@@ -85,14 +85,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push frontend image
|
||||
if: steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
- name: Build and push backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
name: Docker Compose Cucumber tests
|
||||
|
||||
# Reusable workflow called from build.yml when project / docker / testing
|
||||
# sources change. Boots the docker-compose stack and runs the cucumber
|
||||
# scenarios in testing/cucumber.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
docker-base-changed:
|
||||
description: "Whether the docker base image changed (forwarded from files-changed)."
|
||||
required: false
|
||||
type: string
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-compose-tests:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
checks: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
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
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Pip requirements
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Run Docker Compose Tests
|
||||
run: |
|
||||
chmod +x ./testing/test_webpages.sh
|
||||
chmod +x ./testing/test.sh
|
||||
chmod +x ./testing/test_disabledEndpoints.sh
|
||||
./testing/test.sh
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DOCKER_BASE_CHANGED: ${{ inputs.docker-base-changed }}
|
||||
|
||||
- name: Upload Cucumber Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: testing/cucumber/report.html
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: docker-compose-test-reports
|
||||
path: testing/reports/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Cucumber Test Report
|
||||
if: always()
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: Cucumber Tests
|
||||
path: testing/cucumber/junit/*.xml
|
||||
reporter: java-junit
|
||||
fail-on-error: false
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Playwright E2E (live backend)
|
||||
|
||||
# Reusable workflow called from build.yml. Live-backend Playwright suite —
|
||||
# boots Spring Boot and runs auth + real tool round-trips against the live
|
||||
# server.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
playwright-e2e-live:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
- name: Run live E2E tests (chromium)
|
||||
id: live-tests
|
||||
run: task e2e:live
|
||||
- name: Print backend log on failure
|
||||
if: failure() && steps.live-tests.conclusion == 'failure'
|
||||
run: |
|
||||
echo "::group::Spring Boot backend log (last 500 lines)"
|
||||
tail -500 .test-state/playwright/backend.log || echo "no backend log found"
|
||||
echo "::endgroup::"
|
||||
- name: Upload backend log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: backend-log-live-${{ github.run_id }}
|
||||
path: .test-state/playwright/backend.log
|
||||
retention-days: 7
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-live-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Playwright E2E (stubbed)
|
||||
|
||||
# Reusable workflow called from build.yml. Backend-free Playwright suite —
|
||||
# fast, no Spring Boot required. Runs against the `stubbed` project which
|
||||
# mocks API responses in the browser.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
playwright-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
- name: Run stubbed E2E tests (chromium)
|
||||
run: task e2e:stubbed -- --workers=3
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-stubbed-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
@@ -7,6 +7,9 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
@@ -25,7 +28,7 @@ jobs:
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -49,7 +52,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -77,7 +80,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -136,7 +139,7 @@ jobs:
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -189,7 +192,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -274,7 +277,7 @@ jobs:
|
||||
- name: Create Pull Request (Push only)
|
||||
id: cpr
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Frontend 3rd Party Licenses"
|
||||
@@ -313,7 +316,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -338,7 +341,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -366,7 +369,7 @@ jobs:
|
||||
|
||||
- name: Upload artifact on license issues
|
||||
if: env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: backend-dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
@@ -379,7 +382,7 @@ jobs:
|
||||
|
||||
- name: Delete previous backend license check comments
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -409,7 +412,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR - Backend License Check Results
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -492,7 +495,7 @@ jobs:
|
||||
- name: Create Pull Request (push only)
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Backend 3rd Party Licenses"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
name: Frontend lint, type-check, and build
|
||||
|
||||
# Reusable workflow called from build.yml when frontend / testing sources
|
||||
# change. Runs the consolidated `task frontend:check:all` (lint, types,
|
||||
# unit tests, build) and uploads the dist artifact for downstream jobs.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
frontend-validation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Quality-check frontend
|
||||
id: frontend-check
|
||||
run: task frontend:check:all
|
||||
continue-on-error: true
|
||||
- name: Comment on frontend check failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.frontend-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- frontend-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Frontend Check Failed',
|
||||
'',
|
||||
'There are issues with your frontend code that will need to be fixed before they can be merged in.',
|
||||
'',
|
||||
'Run `task frontend:fix` to auto-fix what can be fixed automatically, then run `task frontend:check:all` to see what still needs fixing manually.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
- name: Fail if frontend check failed
|
||||
if: steps.frontend-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Frontend Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "There are issues with your frontend code that"
|
||||
echo "will need to be fixed before they can be merged in."
|
||||
echo ""
|
||||
echo "Run 'task frontend:fix' to auto-fix what can be"
|
||||
echo "fixed automatically, then run 'task frontend:check:all'"
|
||||
echo "to see what still needs fixing manually."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
- name: Remove frontend check comment on success
|
||||
if: steps.frontend-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- frontend-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/dist/
|
||||
retention-days: 3
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -93,18 +93,18 @@ jobs:
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For push/release events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-jars:
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -140,13 +140,13 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
|
||||
|
||||
- name: Upload JAR artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: jar${{ matrix.variant.file_suffix }}
|
||||
path: ./jar-dist/*.jar
|
||||
@@ -188,9 +188,10 @@ jobs:
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: >
|
||||
@@ -207,7 +208,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -217,7 +218,23 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
# x86_64 JDK is set up first so the aarch64 step below can leave its
|
||||
# JAVA_HOME as the active one. The macOS universal JRE build needs
|
||||
# jmods from both arches; the x64 path is captured into the env
|
||||
# before the second setup-java overwrites JAVA_HOME.
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
architecture: "x64"
|
||||
|
||||
- name: Capture x86_64 JAVA_HOME
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
@@ -226,13 +243,21 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
# Build the universal JRE before desktop:prepare so the jlink:runtime
|
||||
# task short-circuits on its `test -d runtime/jre` status check.
|
||||
- name: Build universal macOS JRE
|
||||
if: matrix.platform == 'macos-15'
|
||||
env:
|
||||
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
|
||||
run: task desktop:jlink:universal-mac
|
||||
|
||||
- name: Prepare desktop build
|
||||
run: task desktop:prepare
|
||||
env:
|
||||
@@ -320,7 +345,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -341,7 +366,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -409,9 +434,7 @@ jobs:
|
||||
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/src-tauri/tauri.windows.conf.json
|
||||
|
||||
- name: Import release GPG signing key (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
env:
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
run: |
|
||||
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
|
||||
gpg --list-secret-keys --keyid-format=long
|
||||
@@ -429,7 +452,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@51a9f1156b33df106d827c3a78f8f894946c5faa # v0.5.25
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -443,7 +466,8 @@ jobs:
|
||||
# APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively
|
||||
# SIGN_KEY appimagetool picks the key matching this fingerprint
|
||||
# Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present.
|
||||
SIGN: "1"
|
||||
# Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or V2-master, when secret is present.
|
||||
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -460,7 +484,7 @@ jobs:
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
- name: Clear release GPG key from runner keyring (Linux)
|
||||
if: always() && matrix.platform == 'ubuntu-22.04'
|
||||
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
env:
|
||||
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
run: |
|
||||
@@ -557,7 +581,7 @@ jobs:
|
||||
# Only ship the MSI installer on Windows. The loose exe and WiX toolset exes
|
||||
# are not the user-facing installer - the MSI contains the signed inner exe.
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
@@ -568,7 +592,7 @@ jobs:
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: always() && steps.digicert-setup.conclusion != 'failure'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -582,7 +606,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -35,14 +35,14 @@ jobs:
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Install all Playwright browsers
|
||||
run: task frontend:test:e2e:install
|
||||
run: task e2e:install
|
||||
|
||||
- name: Run E2E tests (all browsers)
|
||||
run: task frontend:test:e2e
|
||||
run: task e2e:cross-browser
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-nightly-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
|
||||
@@ -22,14 +22,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.info.outputs.version }}
|
||||
dmg_arm64_sha256: ${{ steps.hashes.outputs.dmg_arm64_sha256 }}
|
||||
dmg_x86_64_sha256: ${{ steps.hashes.outputs.dmg_x86_64_sha256 }}
|
||||
dmg_sha256: ${{ steps.hashes.outputs.dmg_sha256 }}
|
||||
msi_sha256: ${{ steps.hashes.outputs.msi_sha256 }}
|
||||
deb_sha256: ${{ steps.hashes.outputs.deb_sha256 }}
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -63,17 +62,15 @@ jobs:
|
||||
sha256sum "$file" | awk '{print $1}'
|
||||
}
|
||||
|
||||
DMG_ARM64_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-aarch64.dmg")
|
||||
DMG_X64_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-x86_64.dmg")
|
||||
DMG_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-universal.dmg")
|
||||
MSI_SHA=$(download_sha256 "${BASE}/Stirling-PDF-windows-x86_64.msi")
|
||||
DEB_SHA=$(download_sha256 "${BASE}/Stirling-PDF-linux-x86_64.deb")
|
||||
JAR_SHA=$(download_sha256 "${BASE}/Stirling-PDF-with-login.jar")
|
||||
|
||||
echo "dmg_arm64_sha256=$DMG_ARM64_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "dmg_x86_64_sha256=$DMG_X64_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "msi_sha256=$MSI_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_sha256=$DEB_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "dmg_sha256=$DMG_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "msi_sha256=$MSI_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_sha256=$DEB_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
update-homebrew-and-scoop:
|
||||
needs: get-release-info
|
||||
@@ -82,7 +79,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -93,22 +90,21 @@ jobs:
|
||||
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
path: tap
|
||||
|
||||
# Stirling-PDF now ships a single universal DMG, so the Cask should
|
||||
# have one sha256 line rather than separate on_arm/on_intel blocks.
|
||||
# We rewrite every `sha256 "..."` in the Cask to the same value so
|
||||
# the workflow keeps working through the Cask migration: pre-migration
|
||||
# both arch blocks get the universal SHA (still correct, since both
|
||||
# would resolve to the same DMG), post-migration the lone sha256 line
|
||||
# is updated.
|
||||
- name: Update Homebrew cask (Casks/stirling-pdf.rb)
|
||||
env:
|
||||
VERSION: ${{ needs.get-release-info.outputs.version }}
|
||||
ARM64_SHA: ${{ needs.get-release-info.outputs.dmg_arm64_sha256 }}
|
||||
X64_SHA: ${{ needs.get-release-info.outputs.dmg_x86_64_sha256 }}
|
||||
DMG_SHA: ${{ needs.get-release-info.outputs.dmg_sha256 }}
|
||||
run: |
|
||||
CASK="tap/Casks/stirling-pdf.rb"
|
||||
sed -i "s/version \".*\"/version \"${VERSION}\"/" "$CASK"
|
||||
awk -v arm="$ARM64_SHA" -v x64="$X64_SHA" '
|
||||
/on_arm/ { in_arm=1 }
|
||||
/on_intel/ { in_arm=0; in_intel=1 }
|
||||
/end/ { in_arm=0; in_intel=0 }
|
||||
in_arm && /sha256/ { sub(/sha256 ".*"/, "sha256 \"" arm "\"") }
|
||||
in_intel && /sha256/ { sub(/sha256 ".*"/, "sha256 \"" x64 "\"") }
|
||||
{ print }
|
||||
' "$CASK" > tmp && mv tmp "$CASK"
|
||||
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$CASK"
|
||||
sed -i "s/sha256 \"[^\"]*\"/sha256 \"${DMG_SHA}\"/g" "$CASK"
|
||||
|
||||
- name: Update Homebrew formula (Formula/stirling-pdf-server.rb)
|
||||
env:
|
||||
@@ -116,8 +112,8 @@ jobs:
|
||||
JAR_SHA: ${{ needs.get-release-info.outputs.jar_sha256 }}
|
||||
run: |
|
||||
FORMULA="tap/Formula/stirling-pdf-server.rb"
|
||||
sed -i "s/version \".*\"/version \"${VERSION}\"/" "$FORMULA"
|
||||
sed -i "s/sha256 \".*\"/sha256 \"${JAR_SHA}\"/" "$FORMULA"
|
||||
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$FORMULA"
|
||||
sed -i "s/sha256 \"[^\"]*\"/sha256 \"${JAR_SHA}\"/" "$FORMULA"
|
||||
|
||||
- name: Update Scoop stirling-pdf.json
|
||||
env:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
name: Pre-commit
|
||||
|
||||
# Runs `pre-commit run` for ruff / codespell / gitleaks / EOF / trailing-ws.
|
||||
# Called from build.yml on PRs and merge_group; also runnable on demand via
|
||||
# workflow_dispatch for manual local-equivalent linting.
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -18,7 +19,7 @@ jobs:
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -43,20 +43,20 @@ jobs:
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -64,10 +64,10 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
id: build-push-base
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: docker/base
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
|
||||
@@ -2,6 +2,22 @@ name: Push Docker Image with VersionNumber
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_main_app:
|
||||
description: "Build & push the main Stirling-PDF image (latest, fat, ultra-lite)."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
build_unoserver:
|
||||
description: "Build & push the standalone stirling-unoserver image."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
force_unoserver_rebuild:
|
||||
description: "Rebuild stirling-unoserver even if its source hash is unchanged."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -31,9 +47,13 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
# On push events these stay 'true'; on workflow_dispatch they follow the inputs.
|
||||
env:
|
||||
RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }}
|
||||
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -46,7 +66,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -56,13 +76,13 @@ jobs:
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
@@ -76,24 +96,24 @@ jobs:
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -108,6 +128,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest
|
||||
id: meta
|
||||
if: env.RUN_MAIN_APP == 'true'
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
@@ -122,7 +143,9 @@ jobs:
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
# Empty-tag guard: build-push-action errors when asked to push with no tags.
|
||||
if: env.RUN_MAIN_APP == 'true' && steps.meta.outputs.tags != ''
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -140,7 +163,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-latest.outputs.digest != ''
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
@@ -156,7 +179,7 @@ jobs:
|
||||
- name: Generate tags for latest-fat
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -169,8 +192,8 @@ jobs:
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-fat.outputs.tags != ''
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -186,7 +209,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-fat.outputs.digest != ''
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
@@ -200,7 +223,7 @@ jobs:
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -213,8 +236,8 @@ jobs:
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-lite.outputs.tags != ''
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -230,7 +253,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-lite.outputs.digest != ''
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
@@ -240,3 +263,136 @@ jobs:
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
# Standalone unoserver image — versioned independently via
|
||||
# docker/unoserver/VERSION. master/V2-master: publish <version>+latest
|
||||
# only when the version is new. main/testMain: republish :alpha only
|
||||
# when the source hash differs from the published image's annotation.
|
||||
- name: Read unoserver image version
|
||||
id: unoserverVersion
|
||||
if: env.RUN_UNOSERVER == 'true'
|
||||
run: |
|
||||
version=$(tr -d '[:space:]' < docker/unoserver/VERSION)
|
||||
if [ -z "$version" ]; then
|
||||
echo "docker/unoserver/VERSION is empty"; exit 1
|
||||
fi
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "Unoserver image version (from file): ${version}"
|
||||
|
||||
- name: Compute unoserver image source hash
|
||||
id: unoserverHash
|
||||
if: env.RUN_UNOSERVER == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
hash=$(cat \
|
||||
docker/unoserver/Dockerfile \
|
||||
docker/unoserver/entrypoint.sh \
|
||||
docker/unoserver/healthcheck.sh \
|
||||
docker/unoserver/VERSION \
|
||||
| sha256sum | cut -d' ' -f1)
|
||||
echo "hash=${hash}" >> "$GITHUB_OUTPUT"
|
||||
echo "Unoserver source hash: ${hash}"
|
||||
|
||||
- name: Decide whether to publish unoserver image
|
||||
id: unoserverDecision
|
||||
if: env.RUN_UNOSERVER == 'true'
|
||||
env:
|
||||
UNOSERVER_VERSION: ${{ steps.unoserverVersion.outputs.version }}
|
||||
UNOSERVER_HASH: ${{ steps.unoserverHash.outputs.hash }}
|
||||
UNOSERVER_IMAGE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-unoserver
|
||||
UNOSERVER_HASH_ANNOTATION: org.stirlingpdf.unoserver-source-hash
|
||||
FORCE_REBUILD: ${{ inputs.force_unoserver_rebuild }}
|
||||
GH_REF: ${{ github.ref }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -eu
|
||||
mode="skip"
|
||||
tags=""
|
||||
|
||||
read_published_hash() {
|
||||
local ref="$1"
|
||||
docker buildx imagetools inspect "$ref" --raw 2>/dev/null \
|
||||
| jq -r --arg key "$UNOSERVER_HASH_ANNOTATION" \
|
||||
'.annotations[$key] // empty' \
|
||||
2>/dev/null || true
|
||||
}
|
||||
|
||||
# Manual dispatch from any branch routes to the :alpha publish path.
|
||||
EFFECTIVE_REF="$GH_REF"
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
EFFECTIVE_REF="refs/heads/testMain"
|
||||
fi
|
||||
|
||||
case "$EFFECTIVE_REF" in
|
||||
refs/heads/master|refs/heads/V2-master)
|
||||
if [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
echo "force_unoserver_rebuild=true — building stable regardless"
|
||||
mode="stable"
|
||||
tags="${UNOSERVER_IMAGE}:${UNOSERVER_VERSION},${UNOSERVER_IMAGE}:latest"
|
||||
elif docker manifest inspect "${UNOSERVER_IMAGE}:${UNOSERVER_VERSION}" >/dev/null 2>&1; then
|
||||
echo "stirling-unoserver:${UNOSERVER_VERSION} already on GHCR — skipping"
|
||||
else
|
||||
echo "stirling-unoserver:${UNOSERVER_VERSION} is new — will publish"
|
||||
mode="stable"
|
||||
tags="${UNOSERVER_IMAGE}:${UNOSERVER_VERSION},${UNOSERVER_IMAGE}:latest"
|
||||
fi
|
||||
;;
|
||||
refs/heads/main|refs/heads/testMain)
|
||||
published_hash=$(read_published_hash "${UNOSERVER_IMAGE}:alpha")
|
||||
if [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
echo "force_unoserver_rebuild=true — rebuilding :alpha regardless"
|
||||
mode="alpha"
|
||||
tags="${UNOSERVER_IMAGE}:alpha"
|
||||
elif [ -n "$published_hash" ] && [ "$published_hash" = "$UNOSERVER_HASH" ]; then
|
||||
echo "Published :alpha source hash matches (${published_hash}) — skipping"
|
||||
else
|
||||
if [ -z "$published_hash" ]; then
|
||||
echo ":alpha has no source-hash annotation (first publish or pre-tracking image) — will publish"
|
||||
else
|
||||
echo "Source hash changed (was ${published_hash}, now ${UNOSERVER_HASH}) — will publish"
|
||||
fi
|
||||
mode="alpha"
|
||||
tags="${UNOSERVER_IMAGE}:alpha"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Branch ${GH_REF} does not publish unoserver image"
|
||||
;;
|
||||
esac
|
||||
echo "mode=${mode}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${tags}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push unoserver image
|
||||
id: build-push-unoserver
|
||||
if: env.RUN_UNOSERVER == 'true' && steps.unoserverDecision.outputs.mode != 'skip'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/unoserver/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-unoserver
|
||||
cache-to: type=gha,mode=max,scope=stirling-unoserver
|
||||
tags: ${{ steps.unoserverDecision.outputs.tags }}
|
||||
# Manifest annotation read by the decision step above to detect drift.
|
||||
annotations: |
|
||||
index:org.stirlingpdf.unoserver-source-hash=${{ steps.unoserverHash.outputs.hash }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign unoserver image
|
||||
if: env.RUN_UNOSERVER == 'true' && steps.unoserverDecision.outputs.mode == 'stable'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-unoserver.outputs.digest }}
|
||||
TAGS: ${{ steps.unoserverDecision.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
if [ -n "$COSIGN_PRIVATE_KEY" ]; then
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
else
|
||||
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping unoserver image signing"
|
||||
fi
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -26,13 +26,13 @@ jobs:
|
||||
uses: imjasonh/setup-crane@31b88afe9de28ae0ffa220711af4b60be9435f6e # v0.4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 30 days stale issues
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 30
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: always()
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: Update files
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
name: Build Tauri Applications
|
||||
|
||||
# Multi-OS Tauri desktop bundle build matrix (Windows / macOS universal /
|
||||
# Linux). Called from build.yml on PRs that touch desktop sources (gated
|
||||
# via the `tauri` filter in .github/config/.files.yaml). Also runnable
|
||||
# on demand via workflow_dispatch with a per-platform selector.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)."
|
||||
required: false
|
||||
type: string
|
||||
default: "all"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
@@ -13,17 +24,6 @@ on:
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- "frontend/src-tauri/**"
|
||||
- "frontend/src/desktop/**"
|
||||
- "frontend/tsconfig.desktop.json"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/vite.config.ts"
|
||||
- ".github/workflows/tauri-build.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -45,25 +45,19 @@ jobs:
|
||||
id: set-matrix
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
PLATFORM: ${{ inputs.platform }}
|
||||
run: |
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
|
||||
MACOS_ARM='{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"}'
|
||||
MACOS_INTEL='{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}'
|
||||
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}'
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'
|
||||
|
||||
# Resolve requested platform (non-dispatch events always build all)
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PLATFORM="${{ github.event.inputs.platform }}"
|
||||
else
|
||||
PLATFORM="all"
|
||||
fi
|
||||
|
||||
# Build candidate list
|
||||
# Resolve requested platform — populated by either workflow_dispatch
|
||||
# or workflow_call inputs; both paths default to "all".
|
||||
case "$PLATFORM" in
|
||||
windows) ENTRIES=("$WINDOWS") ;;
|
||||
macos) ENTRIES=("$MACOS_ARM" "$MACOS_INTEL") ;;
|
||||
macos) ENTRIES=("$MACOS") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS_ARM" "$MACOS_INTEL" "$LINUX") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;;
|
||||
esac
|
||||
|
||||
# Drop macOS entries when Apple certificate secret is unavailable
|
||||
@@ -89,9 +83,10 @@ jobs:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -105,7 +100,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -115,7 +110,23 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
# x86_64 JDK is set up first so the aarch64 step below can leave its
|
||||
# JAVA_HOME as the active one. The macOS universal JRE build needs
|
||||
# jmods from both arches; the x64 path is captured into the env
|
||||
# before the second setup-java overwrites JAVA_HOME.
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
architecture: "x64"
|
||||
|
||||
- name: Capture x86_64 JAVA_HOME
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
@@ -124,12 +135,21 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
# Build the universal JRE before desktop:prepare so the jlink:runtime
|
||||
# task short-circuits on its `test -d runtime/jre` status check.
|
||||
- name: Build universal macOS JRE
|
||||
if: matrix.platform == 'macos-15'
|
||||
env:
|
||||
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
|
||||
run: task desktop:jlink:universal-mac
|
||||
|
||||
- name: Prepare desktop build
|
||||
run: task desktop:prepare
|
||||
env:
|
||||
@@ -217,7 +237,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -238,7 +258,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 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
|
||||
@@ -250,7 +270,7 @@ jobs:
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Check DMG creation dependencies (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
|
||||
echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
|
||||
@@ -293,9 +313,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Import release GPG signing key (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
env:
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
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
|
||||
@@ -313,7 +331,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@51a9f1156b33df106d827c3a78f8f894946c5faa # v0.5.25
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -327,7 +345,8 @@ jobs:
|
||||
# APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively
|
||||
# SIGN_KEY appimagetool picks the key matching this fingerprint
|
||||
# Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present.
|
||||
SIGN: "1"
|
||||
# Mirror the Windows/macOS gate: only sign when secret is present AND ref is main (skips PRs from forks/Dependabot).
|
||||
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 }}
|
||||
@@ -340,10 +359,35 @@ jobs:
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
# 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)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
continue-on-error: true
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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 }}
|
||||
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' }} # gitleaks:allow
|
||||
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
|
||||
tauriScript: npx tauri
|
||||
args: --bundles appimage
|
||||
|
||||
- name: Clear release GPG key from runner keyring (Linux)
|
||||
if: always() && matrix.platform == 'ubuntu-22.04'
|
||||
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: |
|
||||
@@ -353,7 +397,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "🔍 Verifying notarization status..."
|
||||
cd ./frontend/src-tauri/target
|
||||
@@ -378,7 +422,7 @@ jobs:
|
||||
# Only ship the MSI installer. The loose exe and WiX toolset exes
|
||||
# are not the user-facing installer - the MSI contains the signed inner exe.
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
@@ -452,7 +496,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -471,7 +515,7 @@ jobs:
|
||||
echo "❌ No Windows executable found"
|
||||
exit 1
|
||||
fi
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
echo "Checking for macOS artifacts..."
|
||||
find . -name "*.dmg" | head -5
|
||||
if [ $(find . -name "*.dmg" | wc -l) -eq 0 ]; then
|
||||
@@ -513,12 +557,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Post/Update PR Comment with Download Links
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
@@ -536,8 +580,7 @@ jobs:
|
||||
// Map of expected artifact names to display info
|
||||
const artifactMap = {
|
||||
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
|
||||
'Stirling-PDF-macos-aarch64': { icon: '🍎', platform: 'macOS ARM64', files: '.dmg' },
|
||||
'Stirling-PDF-macos-x86_64': { icon: '🍎', platform: 'macOS Intel', files: '.dmg' },
|
||||
'Stirling-PDF-macos-universal': { icon: '🍎', platform: 'macOS Universal', files: '.dmg' },
|
||||
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .rpm, .AppImage' }
|
||||
};
|
||||
|
||||
@@ -605,7 +648,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
name: Build Docker images (PR test)
|
||||
|
||||
# Reusable workflow called from build.yml on PRs to verify the three
|
||||
# embedded Dockerfiles (default, ultra-lite, fat) still build cleanly,
|
||||
# optionally against a freshly-built base image when the PR touches the
|
||||
# base Dockerfile.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
docker-base-changed:
|
||||
description: "Whether the docker base image changed (forwarded from files-changed)."
|
||||
required: false
|
||||
type: string
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# TODO: extract a pre-matrix `prepare` job that runs once and produces
|
||||
# shared artifacts for the three matrix entries below to consume:
|
||||
# 1. `task backend:build` — currently runs 3× in parallel with
|
||||
# identical env (DISABLE_ADDITIONAL_FEATURES=true,
|
||||
# STIRLING_PDF_DESKTOP_UI=false). Build once, upload the JAR as an
|
||||
# artifact, matrix entries download.
|
||||
# 2. The base-image `docker build` (gated on docker-base-changed) —
|
||||
# currently runs 3× in parallel against the same Dockerfile and
|
||||
# context. Build once, `docker save` to an artifact, matrix entries
|
||||
# `docker load` before the embedded build.
|
||||
# Saves ~2 full backend builds + 2 base-image builds per PR that touches
|
||||
# docker. May also be reusable from backend-build.yml's jdk-25 +
|
||||
# spring-security=true matrix entry if `task backend:build` and
|
||||
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
|
||||
test-build-docker-images:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- docker-rev: docker/embedded/Dockerfile
|
||||
artifact-suffix: Dockerfile
|
||||
cache-scope: stirling-pdf-latest
|
||||
- docker-rev: docker/embedded/Dockerfile.ultra-lite
|
||||
artifact-suffix: Dockerfile.ultra-lite
|
||||
cache-scope: stirling-pdf-ultra-lite
|
||||
- docker-rev: docker/embedded/Dockerfile.fat
|
||||
artifact-suffix: Dockerfile.fat
|
||||
cache-scope: stirling-pdf-fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Free disk space on runner
|
||||
run: |
|
||||
echo "Disk space before cleanup:" && df -h
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost
|
||||
docker system prune -af || true
|
||||
echo "Disk space after cleanup:" && df -h
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Build application
|
||||
run: task backend:build
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build base image locally (PR base change only)
|
||||
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
|
||||
run: |
|
||||
docker build -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base
|
||||
|
||||
- name: Set base image and platform for this build
|
||||
id: build-params
|
||||
# Pass workflow inputs through env vars rather than expanding `${{ }}`
|
||||
# directly into the shell — defense-in-depth against template injection
|
||||
# if any upstream provider of these values ever becomes less trusted.
|
||||
# GITHUB_EVENT_NAME is already provided by the runner.
|
||||
env:
|
||||
DOCKER_BASE_CHANGED: ${{ inputs.docker-base-changed }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$DOCKER_BASE_CHANGED" = "true" ]; then
|
||||
echo "base_image=stirling-pdf-base:pr-test" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }}
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
cache-from: type=gha,scope=${{ matrix.cache-scope }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
|
||||
platforms: ${{ steps.build-params.outputs.platforms }}
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ steps.build-params.outputs.base_image }}
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Upload Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: reports-docker-${{ matrix.artifact-suffix }}
|
||||
path: |
|
||||
build/reports/tests/
|
||||
build/test-results/
|
||||
build/reports/problems/
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
test-build-unoserver-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/unoserver/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
cache-from: type=gha,scope=stirling-unoserver
|
||||
cache-to: type=gha,mode=max,scope=stirling-unoserver
|
||||
platforms: linux/amd64
|
||||
tags: stirling-unoserver:pr-test
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Smoke test the built image
|
||||
run: |
|
||||
set -eu
|
||||
docker run -d --name unoserver-smoke \
|
||||
-e UNOSERVER_RECYCLE_INTERVAL_SECONDS=0 \
|
||||
stirling-unoserver:pr-test
|
||||
deadline=$((SECONDS + 60))
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
status=$(docker inspect -f '{{.State.Health.Status}}' unoserver-smoke 2>/dev/null || echo "starting")
|
||||
if [ "$status" = "healthy" ]; then
|
||||
echo "unoserver became healthy"
|
||||
docker logs unoserver-smoke | tail -30
|
||||
docker rm -f unoserver-smoke
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "unoserver did not become healthy in time"
|
||||
docker logs unoserver-smoke || true
|
||||
docker rm -f unoserver-smoke || true
|
||||
exit 1
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -61,13 +61,13 @@ jobs:
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push test image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -150,14 +150,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
+9
-3
@@ -32,6 +32,7 @@ exampleYmlFiles/stirling/
|
||||
/testing/cucumber/junit/
|
||||
/testing/cucumber/report.html
|
||||
/testing/.failed_tests
|
||||
/.test-state/
|
||||
SwaggerDoc.json
|
||||
|
||||
# Runtime storage for uploaded files and user data (not Java source code)
|
||||
@@ -50,6 +51,7 @@ app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
app/core/src/main/resources/static/pdfium/
|
||||
app/core/src/main/resources/static/pdfjs/
|
||||
app/core/src/main/resources/static/vendor/
|
||||
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
|
||||
|
||||
@@ -163,9 +165,6 @@ __pycache__/
|
||||
*.pyo
|
||||
|
||||
# Virtual environments
|
||||
.env*
|
||||
!.env*.example
|
||||
!engine/.env
|
||||
.venv*
|
||||
env*/
|
||||
venv*/
|
||||
@@ -173,6 +172,9 @@ ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Env files (secrets / local overrides). Subproject .gitignore files whitelist any committed defaults.
|
||||
.env*
|
||||
|
||||
# VS Code
|
||||
/.vscode/**/*
|
||||
!/.vscode/settings.json
|
||||
@@ -265,3 +267,7 @@ docs/type3/signatures/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
# Playwright MCP screenshots / traces
|
||||
.playwright-mcp/
|
||||
*.playwright-mcp.png
|
||||
|
||||
+26
-48
@@ -1,62 +1,55 @@
|
||||
version: '3'
|
||||
|
||||
# All gradle invocations go through `bash gradlew` (the sh wrapper) so they work
|
||||
# uniformly on Linux/macOS (native bash) and Windows-with-Git-Bash.
|
||||
|
||||
tasks:
|
||||
dev:
|
||||
desc: "Start backend dev server"
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat :stirling-pdf:bootRun
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew :stirling-pdf:bootRun
|
||||
platforms: [linux, darwin]
|
||||
- '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}bash gradlew :stirling-pdf:bootRun'
|
||||
|
||||
dev:bundled:
|
||||
desc: "Clean + bootRun with frontend bundled into the backend (single :8080 server)"
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash gradlew clean bootRun -PbuildWithFrontend=true
|
||||
|
||||
build:
|
||||
desc: "Full backend build"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat clean build
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew clean build
|
||||
platforms: [linux, darwin]
|
||||
- bash gradlew clean build
|
||||
|
||||
build:fast:
|
||||
desc: "Build without tests"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat clean build -x test
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew clean build -x test
|
||||
platforms: [linux, darwin]
|
||||
- bash gradlew clean build -x test
|
||||
|
||||
build:ci:
|
||||
desc: "Build for CI (formatting checked separately)"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat build -PnoSpotless
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew build -PnoSpotless
|
||||
platforms: [linux, darwin]
|
||||
- bash gradlew build -PnoSpotless
|
||||
|
||||
test:
|
||||
desc: "Run backend tests"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat test
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew test
|
||||
platforms: [linux, darwin]
|
||||
- bash gradlew test
|
||||
|
||||
format:
|
||||
desc: "Auto-fix code formatting"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat spotlessApply
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew spotlessApply
|
||||
platforms: [linux, darwin]
|
||||
- bash gradlew spotlessApply
|
||||
|
||||
format:check:
|
||||
desc: "Check code formatting"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat spotlessCheck
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew spotlessCheck
|
||||
platforms: [linux, darwin]
|
||||
- bash gradlew spotlessCheck
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix backend"
|
||||
@@ -66,10 +59,7 @@ tasks:
|
||||
swagger:
|
||||
desc: "Generate OpenAPI docs"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat :stirling-pdf:copySwaggerDoc
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew :stirling-pdf:copySwaggerDoc
|
||||
platforms: [linux, darwin]
|
||||
- bash gradlew :stirling-pdf:copySwaggerDoc
|
||||
sources:
|
||||
- app/core/src/main/java/**/*.java
|
||||
- app/proprietary/src/main/java/**/*.java
|
||||
@@ -87,31 +77,19 @@ tasks:
|
||||
desc: "Print project version"
|
||||
silent: true
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat printVersion --quiet | tail -1
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew printVersion --quiet | tail -1
|
||||
platforms: [linux, darwin]
|
||||
- bash gradlew printVersion --quiet | tail -1
|
||||
|
||||
licenses:check:
|
||||
desc: "Check dependency licenses"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat checkLicense --no-parallel
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew checkLicense --no-parallel
|
||||
platforms: [linux, darwin]
|
||||
- bash gradlew checkLicense --no-parallel
|
||||
|
||||
licenses:generate:
|
||||
desc: "Check and generate dependency license report"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat checkLicense generateLicenseReport --no-parallel
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew checkLicense generateLicenseReport --no-parallel
|
||||
platforms: [linux, darwin]
|
||||
- bash gradlew checkLicense generateLicenseReport --no-parallel
|
||||
|
||||
clean:
|
||||
desc: "Clean build artifacts"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat clean
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew clean
|
||||
platforms: [linux, darwin]
|
||||
- bash gradlew clean
|
||||
|
||||
+21
-2
@@ -6,7 +6,11 @@ vars:
|
||||
tasks:
|
||||
prepare:
|
||||
desc: "Prepare desktop build dependencies"
|
||||
deps: [jlink, ":frontend:prepare:desktop", provisioner]
|
||||
deps:
|
||||
- jlink
|
||||
- task: ":frontend:prepare"
|
||||
vars: { MODE: desktop }
|
||||
- provisioner
|
||||
|
||||
provisioner:
|
||||
desc: "Build installer provisioner"
|
||||
@@ -92,7 +96,7 @@ tasks:
|
||||
jlink
|
||||
--add-modules {{.JLINK_MODULES}}
|
||||
--strip-debug
|
||||
--compress=2
|
||||
--compress=zip-6
|
||||
--no-header-files
|
||||
--no-man-pages
|
||||
--output src-tauri/runtime/jre
|
||||
@@ -103,3 +107,18 @@ tasks:
|
||||
desc: "Remove JLink runtime and bundled JARs"
|
||||
cmds:
|
||||
- rm -rf src-tauri/libs src-tauri/runtime
|
||||
|
||||
# macOS-only. Replaces jlink:runtime's single-arch JRE with a universal
|
||||
# (arm64 + x86_64) one for the universal Tauri shell. Runs the x86_64
|
||||
# jlink under Rosetta on Apple Silicon, so it is opt-in and not part of
|
||||
# the default desktop:build flow. Requires AARCH64_JAVA_HOME and
|
||||
# X64_JAVA_HOME to point at matching JDK installations with jmods/.
|
||||
jlink:universal-mac:
|
||||
desc: "Create universal (arm64+x86_64) JRE for the macOS Tauri build"
|
||||
deps: [jlink:jar]
|
||||
platforms: [darwin]
|
||||
env:
|
||||
JLINK_MODULES: "{{.JLINK_MODULES}}"
|
||||
OUTPUT_DIR: src-tauri/runtime/jre
|
||||
cmds:
|
||||
- scripts/build-universal-mac-jre.sh
|
||||
|
||||
@@ -55,3 +55,9 @@ tasks:
|
||||
desc: "Tail docker compose logs"
|
||||
cmds:
|
||||
- docker compose -f {{.COMPOSE_DIR}}/docker-compose.yml logs -f
|
||||
|
||||
test:
|
||||
desc: "Run full Docker integration test suite (builds all variants and tests them)"
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash testing/test.sh {{.CLI_ARGS}}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
install:
|
||||
desc: "Install Playwright browsers"
|
||||
dir: frontend
|
||||
deps: [ ':frontend:install' ]
|
||||
cmds:
|
||||
- npx playwright install {{.CLI_ARGS}} --with-deps
|
||||
|
||||
stubbed:
|
||||
desc: "Run stubbed E2E tests"
|
||||
dir: frontend
|
||||
deps: [ ':frontend:prepare' ]
|
||||
cmds:
|
||||
- npx playwright test --project=stubbed {{.CLI_ARGS}}
|
||||
|
||||
live:
|
||||
desc: "Run live E2E tests"
|
||||
summary: |
|
||||
Auto-spawns a Spring Boot backend with isolated state under
|
||||
.test-state/playwright/ (purged on every run) so the suite doesn't
|
||||
touch your local DB, settings.yml, backups or customFiles. The
|
||||
backend is stopped automatically when the runner exits.
|
||||
|
||||
Pass extra Playwright flags via -- :
|
||||
task e2e:live -- --headed --grep merge
|
||||
deps:
|
||||
- live:backend
|
||||
- live:runner
|
||||
|
||||
live:backend:
|
||||
internal: true
|
||||
ignore_error: true
|
||||
vars:
|
||||
BASE_DIR: '{{.ROOT_DIR}}/.test-state/playwright'
|
||||
env:
|
||||
STIRLING_BASE_PATH: '{{.BASE_DIR}}'
|
||||
# Suppress the analytics opt-in modal that fires on first admin login.
|
||||
# It renders a Mantine overlay that intercepts pointer events and blocks
|
||||
# FirstLoginSlide from rendering, so the bootstrap spec times out waiting
|
||||
# for the password-change prompt.
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
# NOTE: SECURITY_INITIALLOGIN_USERNAME/PASSWORD are intentionally NOT set.
|
||||
# The live-setup project's bootstrap spec performs the real first-login
|
||||
# flow against the backend's default admin/stirling user, exercising the
|
||||
# forced-password-change UI and leaving the DB at admin/adminadmin for
|
||||
# the rest of the live suite. This is both real coverage of the first-
|
||||
# login flow and a stronger seed than env-var-driven user creation.
|
||||
cmds:
|
||||
# Wrapped in `bash -c` because Task's embedded shell (mvdan/sh) doesn't
|
||||
# support `&` + `$!` + `wait` reliably (gradle was never actually
|
||||
# backgrounded, so `$!` recorded a stale PID and the runner saw an
|
||||
# immediate "backend died"). Real bash backgrounds gradle properly and
|
||||
# gives us a stable PID to clean up.
|
||||
- |
|
||||
bash -c '
|
||||
set -e
|
||||
rm -rf "{{.BASE_DIR}}"
|
||||
mkdir -p "{{.BASE_DIR}}"
|
||||
# Background gradle and record its PID so the runner can clean up
|
||||
# the exact process tree (wrapper + forked Spring Boot JVM) without
|
||||
# resorting to fuzzy `pkill -f` patterns. `wait` keeps this script
|
||||
# alive for the lifetime of gradle so Task'"'"'s parallel deps stay
|
||||
# synchronised.
|
||||
bash gradlew :stirling-pdf:bootRun > "{{.BASE_DIR}}/backend.log" 2>&1 &
|
||||
GRADLE_PID=$!
|
||||
echo $GRADLE_PID > "{{.BASE_DIR}}/backend.pid"
|
||||
wait $GRADLE_PID
|
||||
'
|
||||
|
||||
live:runner:
|
||||
internal: true
|
||||
deps: [ ':frontend:prepare' ]
|
||||
dir: frontend
|
||||
vars:
|
||||
BASE_DIR: '{{.ROOT_DIR}}/.test-state/playwright'
|
||||
cmds:
|
||||
# Wrapped in `bash -c` because Task's embedded shell (mvdan/sh) only
|
||||
# supports `EXIT`/`ERR` in `trap`, not `INT`/`TERM`. CLI_ARGS are
|
||||
# passed positionally so quoted args like --grep "foo bar" survive.
|
||||
- |
|
||||
bash -c '
|
||||
set +e
|
||||
# bootRun forks a separate Spring Boot JVM as a child of the gradle
|
||||
# wrapper; killing only the wrapper leaves that JVM orphaned holding
|
||||
# :8080. Walk the recorded PID'"'"'s process tree via pgrep so we kill
|
||||
# every descendant, then lsof as a last-resort backstop.
|
||||
kill_tree() {
|
||||
local pid=$1
|
||||
for child in $(pgrep -P "$pid" 2>/dev/null); do
|
||||
kill_tree "$child"
|
||||
done
|
||||
kill "$pid" 2>/dev/null || true
|
||||
}
|
||||
cleanup() {
|
||||
if [ -f "{{.BASE_DIR}}/backend.pid" ]; then
|
||||
echo "Stopping backend..."
|
||||
kill_tree "$(cat "{{.BASE_DIR}}/backend.pid")"
|
||||
sleep 1
|
||||
lsof -ti:8080 2>/dev/null | xargs kill 2>/dev/null || true
|
||||
echo "Backend stopped"
|
||||
fi
|
||||
}
|
||||
# Trap so cleanup runs on every exit path: tests pass, tests fail,
|
||||
# backend never starts, user Ctrl-Cs. Without this the gradle JVM
|
||||
# is left orphaned holding :8080 whenever the wait loop times out.
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "Waiting for backend on :8080 (up to 10min; first compile can be slow)..."
|
||||
WAITED=0
|
||||
while [ $WAITED -lt 600 ]; do
|
||||
if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q UP; then
|
||||
echo "Backend ready, starting Playwright"
|
||||
break
|
||||
fi
|
||||
# Bail early if the backend already died: no point waiting 10min.
|
||||
if [ -f "{{.BASE_DIR}}/backend.pid" ]; then
|
||||
BACKEND_PID=$(cat "{{.BASE_DIR}}/backend.pid")
|
||||
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
echo "Backend process exited before becoming ready; last 100 log lines:"
|
||||
tail -100 "{{.BASE_DIR}}/backend.log" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
WAITED=$((WAITED + 2))
|
||||
done
|
||||
if [ $WAITED -ge 600 ]; then
|
||||
echo "Backend did not become ready within 10min, aborting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npx playwright test --project=live "$@"
|
||||
' bash {{.CLI_ARGS}}
|
||||
|
||||
enterprise:
|
||||
desc: "Run enterprise E2E tests"
|
||||
summary: |
|
||||
Requires an already-running keycloak compose stack on :8080. Bring one
|
||||
up first with:
|
||||
task e2e:oauth:up
|
||||
task e2e:saml:up
|
||||
dir: frontend
|
||||
deps: [ ':frontend:prepare' ]
|
||||
cmds:
|
||||
- npx playwright test --project=enterprise {{.CLI_ARGS}}
|
||||
|
||||
cross-browser:
|
||||
desc: "Run stubbed E2E tests on Chromium, Firefox, and WebKit"
|
||||
dir: frontend
|
||||
deps: [ ':frontend:prepare' ]
|
||||
cmds:
|
||||
- npx playwright test --project=stubbed --project=stubbed-firefox --project=stubbed-webkit {{.CLI_ARGS}}
|
||||
|
||||
check:
|
||||
desc: "Run all E2E tests not requiring Docker"
|
||||
cmds:
|
||||
- task: stubbed
|
||||
- task: live
|
||||
|
||||
check:all:
|
||||
desc: "Run all E2E tests"
|
||||
summary: |
|
||||
Includes the enterprise project, which requires a keycloak compose
|
||||
stack on :8080. Bring one up first with:
|
||||
task e2e:oauth:up
|
||||
task e2e:saml:up
|
||||
cmds:
|
||||
- task: stubbed
|
||||
- task: live
|
||||
- task: enterprise
|
||||
|
||||
oauth:up:
|
||||
desc: "Start the OAuth keycloak test environment"
|
||||
summary: |
|
||||
Set LICENSE_KEY=<KEY> to skip the interactive license prompt:
|
||||
task e2e:oauth:up LICENSE_KEY=abc123
|
||||
Pass extra flags via -- :
|
||||
task e2e:oauth:up -- --auto --nobuild
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash testing/compose/start-oauth-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
||||
|
||||
oauth:down:
|
||||
desc: "Stop the OAuth keycloak test environment"
|
||||
cmds:
|
||||
- docker compose -f testing/compose/docker-compose-keycloak-oauth.yml down -v
|
||||
|
||||
saml:up:
|
||||
desc: "Start the SAML keycloak test environment"
|
||||
summary: |
|
||||
Set LICENSE_KEY=<KEY> to skip the interactive license prompt:
|
||||
task e2e:saml:up LICENSE_KEY=abc123
|
||||
Pass extra flags via -- :
|
||||
task e2e:saml:up -- --auto --with-storage --nobuild --language sv-SE
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash testing/compose/start-saml-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
||||
|
||||
saml:down:
|
||||
desc: "Stop the SAML keycloak test environment"
|
||||
cmds:
|
||||
- docker compose -f testing/compose/docker-compose-keycloak-saml.yml down -v
|
||||
@@ -28,20 +28,24 @@ tasks:
|
||||
deps: [prepare]
|
||||
ignore_error: true
|
||||
dir: src
|
||||
vars:
|
||||
PORT: '{{.PORT | default "5001"}}'
|
||||
env:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
cmds:
|
||||
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port 5001
|
||||
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}}
|
||||
|
||||
dev:
|
||||
desc: "Start engine dev server with hot reload"
|
||||
deps: [prepare]
|
||||
ignore_error: true
|
||||
dir: src
|
||||
vars:
|
||||
PORT: '{{.PORT | default "5001"}}'
|
||||
env:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
cmds:
|
||||
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port 5001 --reload
|
||||
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --reload
|
||||
|
||||
lint:
|
||||
desc: "Run linting"
|
||||
@@ -71,7 +75,7 @@ tasks:
|
||||
desc: "Run type checking"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run pyright . --warnings
|
||||
- uv run pyrefly check
|
||||
|
||||
test:
|
||||
desc: "Run tests"
|
||||
|
||||
+59
-77
@@ -15,47 +15,21 @@ tasks:
|
||||
CI: '{{ .CI | default "false" }}'
|
||||
|
||||
prepare:env:
|
||||
desc: "Generate .env from example if missing"
|
||||
run: once
|
||||
internal: true
|
||||
run: when_changed
|
||||
deps: [install]
|
||||
vars:
|
||||
MODE: '{{.MODE | default ""}}'
|
||||
cmds:
|
||||
- npx tsx scripts/setup-env.ts
|
||||
- npx tsx scripts/setup-env.ts{{if .MODE}} --{{.MODE}}{{end}}
|
||||
sources:
|
||||
- scripts/setup-env.ts
|
||||
- config/.env.example
|
||||
generates:
|
||||
- .env
|
||||
|
||||
prepare:env:saas:
|
||||
desc: "Generate .env and .env.saas from examples if missing"
|
||||
run: once
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx tsx scripts/setup-env.ts --saas
|
||||
sources:
|
||||
- scripts/setup-env.ts
|
||||
- config/.env.example
|
||||
- config/.env.saas.example
|
||||
generates:
|
||||
- .env
|
||||
- .env.saas
|
||||
|
||||
prepare:env:desktop:
|
||||
desc: "Generate .env and .env.desktop from examples if missing"
|
||||
run: once
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx tsx scripts/setup-env.ts --desktop
|
||||
sources:
|
||||
- scripts/setup-env.ts
|
||||
- config/.env.example
|
||||
- config/.env.desktop.example
|
||||
generates:
|
||||
- .env
|
||||
- .env.desktop
|
||||
- .env.local
|
||||
- .env{{if .MODE}}.{{.MODE}}{{end}}.local
|
||||
|
||||
prepare:icons:
|
||||
desc: "Generate icon bundle from source references"
|
||||
internal: true
|
||||
run: once
|
||||
deps: [install]
|
||||
cmds:
|
||||
@@ -63,64 +37,75 @@ tasks:
|
||||
|
||||
prepare:
|
||||
desc: "Set up dev environment"
|
||||
run: once
|
||||
deps: [prepare:env, prepare:icons]
|
||||
|
||||
prepare:saas:
|
||||
desc: "Prepare for SaaS mode"
|
||||
run: once
|
||||
deps: [prepare:env:saas, prepare:icons]
|
||||
|
||||
prepare:desktop:
|
||||
desc: "Prepare for desktop mode"
|
||||
run: once
|
||||
deps: [prepare:env:desktop, prepare:icons]
|
||||
run: when_changed
|
||||
vars:
|
||||
MODE: '{{.MODE | default ""}}'
|
||||
deps:
|
||||
- task: prepare:env
|
||||
vars: { MODE: '{{.MODE}}' }
|
||||
- prepare:icons
|
||||
|
||||
# ============================================================
|
||||
# Development
|
||||
# ============================================================
|
||||
|
||||
dev:_run:
|
||||
internal: true
|
||||
ignore_error: true
|
||||
vars:
|
||||
MODE: '{{.MODE}}'
|
||||
PORT: '{{.PORT | default "5173"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- npx vite --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
dev:
|
||||
desc: "Start frontend dev server"
|
||||
deps: [prepare]
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- npx vite
|
||||
- task: dev:proprietary
|
||||
vars: { PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
dev:core:
|
||||
desc: "Start frontend dev server in core mode"
|
||||
deps: [prepare]
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- npx vite --mode core
|
||||
- task: dev:_run
|
||||
vars: { MODE: core, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
dev:proprietary:
|
||||
desc: "Start frontend dev server in proprietary mode"
|
||||
deps: [prepare]
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- npx vite --mode proprietary
|
||||
- task: dev:_run
|
||||
vars: { MODE: proprietary, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
dev:saas:
|
||||
desc: "Start frontend dev server in SaaS mode"
|
||||
deps: [prepare:saas]
|
||||
ignore_error: true
|
||||
deps:
|
||||
- task: prepare
|
||||
vars: { MODE: saas }
|
||||
cmds:
|
||||
- npx vite --mode saas
|
||||
- task: dev:_run
|
||||
vars: { MODE: saas, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
dev:desktop:
|
||||
desc: "Start frontend dev server in desktop mode"
|
||||
deps: [prepare:desktop]
|
||||
ignore_error: true
|
||||
deps:
|
||||
- task: prepare
|
||||
vars: { MODE: desktop }
|
||||
cmds:
|
||||
- npx vite --mode desktop
|
||||
- task: dev:_run
|
||||
vars: { MODE: desktop, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
dev:prototypes:
|
||||
desc: "Start frontend dev server in prototypes mode"
|
||||
deps: [prepare]
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- npx vite --mode prototypes
|
||||
- task: dev:_run
|
||||
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
@@ -146,13 +131,17 @@ tasks:
|
||||
|
||||
build:saas:
|
||||
desc: "Build for SaaS mode"
|
||||
deps: [prepare:saas]
|
||||
deps:
|
||||
- task: prepare
|
||||
vars: { MODE: saas }
|
||||
cmds:
|
||||
- npx vite build --mode saas
|
||||
|
||||
build:desktop:
|
||||
desc: "Build for desktop mode"
|
||||
deps: [prepare:desktop]
|
||||
deps:
|
||||
- task: prepare
|
||||
vars: { MODE: desktop }
|
||||
cmds:
|
||||
- npx vite build --mode desktop
|
||||
|
||||
@@ -216,13 +205,17 @@ tasks:
|
||||
|
||||
typecheck:saas:
|
||||
desc: "Typecheck SaaS build variant"
|
||||
deps: [prepare:saas]
|
||||
deps:
|
||||
- task: prepare
|
||||
vars: { MODE: saas }
|
||||
cmds:
|
||||
- npx tsc --noEmit --project src/saas/tsconfig.json
|
||||
|
||||
typecheck:desktop:
|
||||
desc: "Typecheck desktop build variant"
|
||||
deps: [prepare:desktop]
|
||||
deps:
|
||||
- task: prepare
|
||||
vars: { MODE: desktop }
|
||||
cmds:
|
||||
- npx tsc --noEmit --project src/desktop/tsconfig.json
|
||||
|
||||
@@ -246,6 +239,7 @@ tasks:
|
||||
- task: typecheck:saas
|
||||
- task: typecheck:desktop
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
|
||||
# ============================================================
|
||||
# Quality Gate
|
||||
@@ -290,18 +284,6 @@ tasks:
|
||||
cmds:
|
||||
- npx vitest --coverage
|
||||
|
||||
test:e2e:
|
||||
desc: "Run E2E tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx playwright test {{.CLI_ARGS}}
|
||||
|
||||
test:e2e:install:
|
||||
desc: "Install E2E test browsers"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx playwright install {{.CLI_ARGS}} --with-deps
|
||||
|
||||
# ============================================================
|
||||
# Code Generation
|
||||
# ============================================================
|
||||
|
||||
@@ -6,6 +6,8 @@ This file provides guidance to AI Agents when working with code in this reposito
|
||||
|
||||
This project uses [Task](https://taskfile.dev/) as a unified command runner. All build, dev, test, lint, and docker commands can be run from the repo root via `task <command>`. Run `task --list` to see all available commands.
|
||||
|
||||
Task `desc:` fields should describe **what** the task does, not **how** it does it. Keep them generic and stable: don't reference implementation details like aliases, internal helpers, mode flags, or which other task delegates to which. The description is for users picking a command from `task --list`, not a changelog of refactors.
|
||||
|
||||
### Quick Reference
|
||||
- `task install` — install all dependencies
|
||||
- `task dev` — start backend + frontend concurrently
|
||||
@@ -136,12 +138,14 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
- **Development**: `task desktop:dev` for desktop dev mode
|
||||
|
||||
#### Environment Variables
|
||||
- All `VITE_*` variables must be declared in the appropriate example file:
|
||||
- `frontend/config/.env.example` — core, proprietary, and shared vars
|
||||
- `frontend/config/.env.saas.example` — SaaS-only vars
|
||||
- `frontend/config/.env.desktop.example` — desktop (Tauri)-only vars
|
||||
- Never use `|| 'hardcoded-fallback'` inline — put defaults in the example files
|
||||
- `task frontend:prepare` / `prepare:saas` / `prepare:desktop` auto-create the env files from examples on first run, and error if any required keys are missing
|
||||
- All `VITE_*` variables must be declared in the appropriate committed env file:
|
||||
- `frontend/.env` — core, proprietary, and shared vars
|
||||
- `frontend/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
|
||||
- `frontend/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
|
||||
- These files are committed to Git and must not contain private keys
|
||||
- Local overrides (API keys, machine-specific settings) go in uncommitted sibling `.env.local` / `.env.saas.local` / `.env.desktop.local` files — Vite automatically layers them on top
|
||||
- Never use `|| 'hardcoded-fallback'` inline — put defaults in the committed env files
|
||||
- `task frontend:prepare` creates empty `.local` override files on first run; pass `MODE=saas` or `MODE=desktop` to also create the mode-specific `.local` file
|
||||
- Prepare runs automatically as a dependency of all `dev*`, `build*`, and `desktop*` tasks
|
||||
- See `frontend/README.md#environment-variables` for full documentation
|
||||
|
||||
|
||||
+40
-7
@@ -2,6 +2,10 @@ version: '3'
|
||||
|
||||
output: prefixed
|
||||
|
||||
vars:
|
||||
FIND_FREE_PORT_PS: powershell -NoProfile -File scripts\find-free-port.ps1 -Preferred
|
||||
FIND_FREE_PORT_SH: bash scripts/find-free-port.sh
|
||||
|
||||
includes:
|
||||
backend:
|
||||
taskfile: .taskfiles/backend.yml
|
||||
@@ -18,6 +22,9 @@ includes:
|
||||
desktop:
|
||||
taskfile: .taskfiles/desktop.yml
|
||||
dir: frontend
|
||||
e2e:
|
||||
taskfile: .taskfiles/e2e.yml
|
||||
dir: .
|
||||
|
||||
tasks:
|
||||
# ============================================================
|
||||
@@ -35,17 +42,43 @@ tasks:
|
||||
# ============================================================
|
||||
|
||||
dev:
|
||||
desc: "Start backend + frontend concurrently"
|
||||
desc: "Start backend + frontend 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}}'
|
||||
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- backend:dev
|
||||
- frontend:dev
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:all:
|
||||
desc: "Start backend + frontend + engine concurrently"
|
||||
desc: "Start backend + frontend + engine concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080,5173,5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
ENGINE_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
deps:
|
||||
- backend:dev
|
||||
- frontend:dev:prototypes
|
||||
- engine:dev
|
||||
- task: engine:dev
|
||||
vars:
|
||||
PORT: '{{.ENGINE_PORT}}'
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
||||
- task: frontend:dev:prototypes
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
|
||||
@@ -29,23 +29,23 @@ spotless {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
api 'com.google.guava:guava:33.5.0-jre'
|
||||
api 'com.google.guava:guava:33.6.0-jre'
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
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:3.20.0'
|
||||
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
|
||||
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"
|
||||
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:7.5.8' // RAR archive support for CBR files
|
||||
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.2"
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
|
||||
// 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
|
||||
|
||||
@@ -68,6 +68,36 @@ public class EndpointConfiguration {
|
||||
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a full request URI like {@code /api/v1/general/remove-pages} into the endpoint key
|
||||
* used by this configuration ({@code remove-pages}). Convert endpoints are a special case -
|
||||
* {@code /api/v1/convert/pdf/img} is registered as {@code pdf-to-img}. Returns {@code null} if
|
||||
* the URI is not an {@code /api/v1/<group>/<endpoint>} path.
|
||||
*/
|
||||
public static String endpointKeyForUri(String uri) {
|
||||
if (uri == null || !uri.contains("/api/v1")) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = uri.split("/");
|
||||
if (parts.length <= 4) {
|
||||
return null;
|
||||
}
|
||||
if ("convert".equals(parts[3]) && parts.length > 5) {
|
||||
return parts[4] + "-to-" + parts[5];
|
||||
}
|
||||
return parts[4];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper around {@link #isEndpointEnabled(String)} that accepts a full request URI
|
||||
* and translates it to the endpoint key. Falls back to treating the URI itself as a key for
|
||||
* non-{@code /api/v1/...} paths so callers can pass arbitrary URIs.
|
||||
*/
|
||||
public boolean isEndpointEnabledForUri(String uri) {
|
||||
String key = endpointKeyForUri(uri);
|
||||
return isEndpointEnabled(key != null ? key : uri);
|
||||
}
|
||||
|
||||
public void enableEndpoint(String endpoint) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
endpointStatuses.put(normalized, true);
|
||||
|
||||
+12
@@ -59,6 +59,18 @@ public class InstallationPathConfig {
|
||||
}
|
||||
|
||||
private static String initializeBasePath() {
|
||||
// Allow tests / harnesses to redirect the entire state tree (configs,
|
||||
// backups, customFiles, pipeline, logs) to an isolated location via
|
||||
// -Dstirling.base-path=... or STIRLING_BASE_PATH=... so a Playwright
|
||||
// run never touches a developer's working state.
|
||||
String override = System.getProperty("stirling.base-path");
|
||||
if (override == null || override.isBlank()) {
|
||||
override = System.getenv("STIRLING_BASE_PATH");
|
||||
}
|
||||
if (override != null && !override.isBlank()) {
|
||||
boolean hasTrailingSeparator = override.endsWith("/") || override.endsWith("\\");
|
||||
return hasTrailingSeparator ? override : override + File.separator;
|
||||
}
|
||||
return "." + File.separator;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ public class ApplicationProperties {
|
||||
private ProcessExecutor processExecutor = new ProcessExecutor();
|
||||
private PdfEditor pdfEditor = new PdfEditor();
|
||||
private AiEngine aiEngine = new AiEngine();
|
||||
private InternalApi internalApi = new InternalApi();
|
||||
|
||||
@Bean
|
||||
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
|
||||
@@ -237,6 +238,26 @@ public class ApplicationProperties {
|
||||
private boolean enabled = false;
|
||||
private String url = "http://localhost:5001";
|
||||
private int timeoutSeconds = 120;
|
||||
|
||||
/**
|
||||
* Longer timeout for heavy operations like RAG ingestion, which embeds the whole document
|
||||
* and can take multiple minutes for large books. Applied per-call when the caller
|
||||
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
|
||||
*/
|
||||
private int longRunningTimeoutSeconds = 600;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP timeouts for loopback calls to internal Stirling API endpoints, used by the AI workflow
|
||||
* executor and the pipeline processor. A bounded read timeout prevents a hung tool (e.g. an
|
||||
* infinite loop in a PDF processing service) from stalling the entire chat workflow forever.
|
||||
* Tools that legitimately need longer than the read timeout should be invoked through the async
|
||||
* job executor instead of synchronously.
|
||||
*/
|
||||
@Data
|
||||
public static class InternalApi {
|
||||
private int connectTimeoutSeconds = 10;
|
||||
private int readTimeoutSeconds = 300;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.common.model.api.comments;
|
||||
|
||||
/**
|
||||
* Absolute position of a PDF annotation in the document.
|
||||
*
|
||||
* <p>Coordinates are in PDF user-space with the origin at the page's bottom-left, consistent with
|
||||
* PDFBox's {@code PDRectangle} convention.
|
||||
*
|
||||
* @param pageIndex 0-indexed page number the annotation lives on.
|
||||
* @param x bottom-left x coordinate of the annotation rectangle.
|
||||
* @param y bottom-left y coordinate of the annotation rectangle.
|
||||
* @param width width of the annotation rectangle, in user-space units.
|
||||
* @param height height of the annotation rectangle, in user-space units.
|
||||
*/
|
||||
public record AnnotationLocation(int pageIndex, float x, float y, float width, float height) {}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.common.model.api.comments;
|
||||
|
||||
/**
|
||||
* Description of a single sticky-note (PDF Text) annotation to place on a document.
|
||||
*
|
||||
* <p>{@code author} and {@code subject} are optional — callers that pass {@code null} get a default
|
||||
* author/subject from {@code PdfAnnotationService}.
|
||||
*
|
||||
* @param location where to anchor the annotation icon, in PDF user-space.
|
||||
* @param text the comment body shown in the popup (required, non-blank).
|
||||
* @param author optional author label shown in the popup; {@code null} → service default.
|
||||
* @param subject optional subject line shown in the popup; {@code null} → service default.
|
||||
*/
|
||||
public record StickyNoteSpec(
|
||||
AnnotationLocation location, String text, String author, String subject) {}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package stirling.software.common.model.api.general;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
public class EditTextOperation {
|
||||
|
||||
@Schema(description = "The literal text to find.", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String find;
|
||||
|
||||
@Schema(
|
||||
description = "The replacement text. May be empty to delete the matched text.",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String replace;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import java.io.UncheckedIOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.time.Duration;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -12,15 +13,18 @@ import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RequestCallback;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import jakarta.servlet.ServletContext;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
@@ -34,26 +38,45 @@ import stirling.software.common.util.TempFileManager;
|
||||
@Slf4j
|
||||
public class InternalApiClient {
|
||||
|
||||
// Allowlist for internal dispatch. Matches a fixed namespace prefix,
|
||||
// Allowlist for internal dispatch. Matches fixed namespace prefixes,
|
||||
// but rejects traversal (..), URL-encoding (%), query/fragment, backslashes, and any other
|
||||
// character that could alter the resolved endpoint on the local Spring server.
|
||||
//
|
||||
// The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are
|
||||
// dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is
|
||||
// intentionally NOT permitted to avoid plan steps re-entering the orchestrator.
|
||||
private static final Pattern ALLOWED_ENDPOINT_PATH =
|
||||
Pattern.compile("^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$");
|
||||
Pattern.compile(
|
||||
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
|
||||
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
|
||||
|
||||
private final ServletContext servletContext;
|
||||
private final UserServiceInterface userService;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final Environment environment;
|
||||
private final Duration readTimeout;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public InternalApiClient(
|
||||
ServletContext servletContext,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
TempFileManager tempFileManager,
|
||||
Environment environment) {
|
||||
Environment environment,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.servletContext = servletContext;
|
||||
this.userService = userService;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.environment = environment;
|
||||
ApplicationProperties.InternalApi internalApi = applicationProperties.getInternalApi();
|
||||
// A bounded read timeout is what protects the workflow when an internal tool hangs
|
||||
// (e.g. an infinite loop in a PDF processing service). The connect timeout is short
|
||||
// because this is a loopback call; if connecting takes longer than a few seconds the
|
||||
// local server is itself unhealthy.
|
||||
this.readTimeout = Duration.ofSeconds(internalApi.getReadTimeoutSeconds());
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(Duration.ofSeconds(internalApi.getConnectTimeoutSeconds()));
|
||||
factory.setReadTimeout(readTimeout);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +91,6 @@ public class InternalApiClient {
|
||||
validateUrl(endpointPath);
|
||||
String url = getBaseUrl() + endpointPath;
|
||||
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
String apiKey = getApiKeyForUser();
|
||||
if (apiKey != null && !apiKey.isEmpty()) {
|
||||
@@ -78,26 +100,38 @@ public class InternalApiClient {
|
||||
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
|
||||
|
||||
return restTemplate.execute(
|
||||
url,
|
||||
HttpMethod.POST,
|
||||
requestCallback,
|
||||
response -> {
|
||||
try {
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("internal-api");
|
||||
Files.copy(
|
||||
response.getBody(),
|
||||
tempFile.getPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
String filename = extractFilename(response.getHeaders());
|
||||
TempFileResource resource = new TempFileResource(tempFile, filename);
|
||||
return ResponseEntity.status(response.getStatusCode())
|
||||
.headers(response.getHeaders())
|
||||
.body(resource);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
try {
|
||||
return restTemplate.execute(
|
||||
url,
|
||||
HttpMethod.POST,
|
||||
requestCallback,
|
||||
response -> {
|
||||
try {
|
||||
TempFile tempFile =
|
||||
tempFileManager.createManagedTempFile("internal-api");
|
||||
Files.copy(
|
||||
response.getBody(),
|
||||
tempFile.getPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
String filename = extractFilename(response.getHeaders());
|
||||
TempFileResource resource = new TempFileResource(tempFile, filename);
|
||||
return ResponseEntity.status(response.getStatusCode())
|
||||
.headers(response.getHeaders())
|
||||
.body(resource);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
} catch (ResourceAccessException e) {
|
||||
// RestTemplate wraps low-level I/O failures in ResourceAccessException. Only the
|
||||
// SocketTimeoutException-rooted case is a real timeout; other I/O failures (connection
|
||||
// refused, DNS, etc.) propagate as-is so the upstream generic handler can describe
|
||||
// them accurately.
|
||||
if (e.getCause() instanceof java.net.SocketTimeoutException) {
|
||||
throw new InternalApiTimeoutException(endpointPath, readTimeout, e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Thrown when an internal Stirling tool invocation exceeds its configured read timeout or otherwise
|
||||
* fails at the transport layer. Distinguishes a hung/timed-out tool from a tool that returned a
|
||||
* non-2xx HTTP response (which is reported via the response status itself), so callers can present
|
||||
* a clear "the tool didn't respond in time" message to the user instead of a generic stack trace.
|
||||
*/
|
||||
public class InternalApiTimeoutException extends RuntimeException {
|
||||
|
||||
private final String endpointPath;
|
||||
private final Duration readTimeout;
|
||||
|
||||
public InternalApiTimeoutException(String endpointPath, Duration readTimeout, Throwable cause) {
|
||||
super(buildMessage(endpointPath, readTimeout, cause), cause);
|
||||
this.endpointPath = endpointPath;
|
||||
this.readTimeout = readTimeout;
|
||||
}
|
||||
|
||||
public String getEndpointPath() {
|
||||
return endpointPath;
|
||||
}
|
||||
|
||||
public Duration getReadTimeout() {
|
||||
return readTimeout;
|
||||
}
|
||||
|
||||
private static String buildMessage(String endpointPath, Duration readTimeout, Throwable cause) {
|
||||
String reason = cause != null && cause.getMessage() != null ? cause.getMessage() : "";
|
||||
return String.format(
|
||||
"Internal tool %s did not respond within %ds (%s)",
|
||||
endpointPath, readTimeout.toSeconds(), reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.comments.AnnotationLocation;
|
||||
import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
|
||||
/**
|
||||
* Shared primitive for adding sticky-note (PDF Text) annotations to a document.
|
||||
*
|
||||
* <p>Used by:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code /api/v1/misc/add-comments} — a deterministic, reusable tool.
|
||||
* <li>AI-agent flows that generate comment specs (e.g. PDF review agent, math auditor review
|
||||
* mode) and hand them off to this service for deterministic placement.
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PdfAnnotationService {
|
||||
|
||||
/** Yellow sticky-note fill colour (R, G, B in 0..1 range). */
|
||||
private static final float[] STICKY_NOTE_COLOR_RGB = {1f, 0.95f, 0.4f};
|
||||
|
||||
/** Opacity for the sticky-note icon. */
|
||||
private static final float ANNOTATION_OPACITY = 0.9f;
|
||||
|
||||
/** PDF Text-annotation icon name — {@code "Comment"} is one of the standard icons. */
|
||||
private static final String ANNOTATION_ICON_NAME = "Comment";
|
||||
|
||||
/** Default subject shown in the annotation popup when a spec does not supply one. */
|
||||
private static final String DEFAULT_SUBJECT = "Stirling AI Comment";
|
||||
|
||||
/** Default author label shown in the annotation popup when a spec does not supply one. */
|
||||
private static final String DEFAULT_AUTHOR = "Stirling AI";
|
||||
|
||||
/**
|
||||
* Cap on sticky-note text length. PDF annotation bodies can technically be much longer, but
|
||||
* anything beyond this is almost certainly pathological (accidental document-dump or malicious
|
||||
* payload) and would bloat the output file.
|
||||
*/
|
||||
private static final int MAX_COMMENT_TEXT_LENGTH = 100_000;
|
||||
|
||||
/**
|
||||
* Add a list of sticky notes to {@code doc}. Specs that reference an out-of-range page or
|
||||
* contain blank text are logged and skipped; this method never throws for a single bad spec.
|
||||
*
|
||||
* @return the number of annotations actually applied
|
||||
*/
|
||||
public int addStickyNotes(PDDocument doc, List<StickyNoteSpec> specs) {
|
||||
if (specs == null || specs.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int totalPages = doc.getNumberOfPages();
|
||||
Calendar now = Calendar.getInstance();
|
||||
int applied = 0;
|
||||
for (int i = 0; i < specs.size(); i++) {
|
||||
StickyNoteSpec spec = specs.get(i);
|
||||
if (!isValid(spec, totalPages, i)) {
|
||||
continue;
|
||||
}
|
||||
apply(doc, spec, now);
|
||||
applied++;
|
||||
}
|
||||
if (applied < specs.size()) {
|
||||
log.warn(
|
||||
"Applied {}/{} sticky notes; {} skipped due to invalid specs.",
|
||||
applied,
|
||||
specs.size(),
|
||||
specs.size() - applied);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single sticky note. Convenience wrapper; prefer {@link #addStickyNotes(PDDocument,
|
||||
* List)} when placing multiple annotations so log output is batched.
|
||||
*/
|
||||
public void addStickyNote(PDDocument doc, StickyNoteSpec spec) {
|
||||
addStickyNotes(doc, List.of(spec));
|
||||
}
|
||||
|
||||
private boolean isValid(StickyNoteSpec spec, int totalPages, int index) {
|
||||
if (spec == null || spec.location() == null) {
|
||||
log.warn("Skipping sticky-note[{}]: spec or location is null.", index);
|
||||
return false;
|
||||
}
|
||||
if (spec.text() == null || spec.text().isBlank()) {
|
||||
log.warn("Skipping sticky-note[{}]: text is blank.", index);
|
||||
return false;
|
||||
}
|
||||
if (spec.text().length() > MAX_COMMENT_TEXT_LENGTH) {
|
||||
log.warn(
|
||||
"Skipping sticky-note[{}]: text length {} exceeds limit {}.",
|
||||
index,
|
||||
spec.text().length(),
|
||||
MAX_COMMENT_TEXT_LENGTH);
|
||||
return false;
|
||||
}
|
||||
AnnotationLocation loc = spec.location();
|
||||
if (loc.width() <= 0f || loc.height() <= 0f) {
|
||||
log.warn(
|
||||
"Skipping sticky-note[{}]: non-positive dimensions width={} height={}.",
|
||||
index,
|
||||
loc.width(),
|
||||
loc.height());
|
||||
return false;
|
||||
}
|
||||
int page = loc.pageIndex();
|
||||
if (page < 0 || page >= totalPages) {
|
||||
log.warn(
|
||||
"Skipping sticky-note[{}]: pageIndex={} out of range [0, {}).",
|
||||
index,
|
||||
page,
|
||||
totalPages);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void apply(PDDocument doc, StickyNoteSpec spec, Calendar now) {
|
||||
AnnotationLocation loc = spec.location();
|
||||
|
||||
PDAnnotationText annot = new PDAnnotationText();
|
||||
annot.setContents(spec.text());
|
||||
annot.setRectangle(new PDRectangle(loc.x(), loc.y(), loc.width(), loc.height()));
|
||||
annot.setSubject(nonBlankOr(spec.subject(), DEFAULT_SUBJECT));
|
||||
annot.setTitlePopup(nonBlankOr(spec.author(), DEFAULT_AUTHOR));
|
||||
annot.setColor(new PDColor(STICKY_NOTE_COLOR_RGB, PDDeviceRGB.INSTANCE));
|
||||
annot.setCreationDate(now);
|
||||
annot.setConstantOpacity(ANNOTATION_OPACITY);
|
||||
annot.getCOSObject().setName(COSName.NAME, ANNOTATION_ICON_NAME);
|
||||
|
||||
try {
|
||||
doc.getPage(loc.pageIndex()).getAnnotations().add(annot);
|
||||
} catch (java.io.IOException e) {
|
||||
log.warn(
|
||||
"Failed to attach sticky note to page {}: {}", loc.pageIndex(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String nonBlankOr(String value, String fallback) {
|
||||
return value != null && !value.isBlank() ? value : fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.pdfbox.text.TextPosition;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Locate text on a specific PDF page and return its bounding box in PDF user-space (bottom-left
|
||||
* origin). Used by tools that receive "anchor by text" hints — e.g. {@code
|
||||
* /api/v1/misc/add-comments} when callers supply an {@code anchorText} instead of explicit
|
||||
* coordinates.
|
||||
*
|
||||
* <p>Matching is tolerant: case-insensitive with punctuation/whitespace stripped on both sides, so
|
||||
* a caller-supplied needle of {@code "215000"} matches page text {@code "$215,000"}, and {@code
|
||||
* "Total Revenue"} matches {@code "Total Revenue."}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PdfTextLocator {
|
||||
|
||||
/** One found line of text with its user-space bounding box. */
|
||||
public record MatchedBox(float x, float y, float width, float height) {}
|
||||
|
||||
/**
|
||||
* Find the first line on {@code pageIndex} (0-indexed) whose text contains {@code needle} under
|
||||
* the tolerant match. Returns empty when no match, when the page index is out of range, or when
|
||||
* the needle is blank.
|
||||
*/
|
||||
public Optional<MatchedBox> findOnPage(PDDocument doc, int pageIndex, String needle) {
|
||||
if (doc == null
|
||||
|| needle == null
|
||||
|| needle.isBlank()
|
||||
|| pageIndex < 0
|
||||
|| pageIndex >= doc.getNumberOfPages()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String normalizedNeedle = normalize(needle);
|
||||
if (normalizedNeedle.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
List<CapturedLine> lines = new ArrayList<>();
|
||||
LineCapturingStripper stripper;
|
||||
try {
|
||||
stripper = new LineCapturingStripper(lines);
|
||||
stripper.setStartPage(pageIndex + 1);
|
||||
stripper.setEndPage(pageIndex + 1);
|
||||
stripper.setSortByPosition(true);
|
||||
// Side effect: populates `lines`. We don't need the concatenated text.
|
||||
stripper.getText(doc);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"PdfTextLocator failed to extract text on page {}: {}",
|
||||
pageIndex,
|
||||
e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
PDRectangle mediaBox = doc.getPage(pageIndex).getMediaBox();
|
||||
float pageHeight = mediaBox.getHeight();
|
||||
|
||||
for (CapturedLine line : lines) {
|
||||
if (normalize(line.text).contains(normalizedNeedle)) {
|
||||
// PDFBox's *DirAdj coords descend from the top of the page; convert to PDF
|
||||
// user-space (origin = bottom-left) so the bbox can feed a PDRectangle directly.
|
||||
float userSpaceY = pageHeight - line.yTopDown - line.height;
|
||||
return Optional.of(new MatchedBox(line.x, userSpaceY, line.width, line.height));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Strip everything non-alphanumeric and lowercase for tolerant matching. */
|
||||
private static String normalize(String s) {
|
||||
return s.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static final class CapturedLine {
|
||||
String text;
|
||||
float x;
|
||||
float yTopDown;
|
||||
float width;
|
||||
float height;
|
||||
}
|
||||
|
||||
private static final class LineCapturingStripper extends PDFTextStripper {
|
||||
private final List<CapturedLine> lines;
|
||||
|
||||
LineCapturingStripper(List<CapturedLine> sink) throws IOException {
|
||||
super();
|
||||
this.lines = sink;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeString(String text, List<TextPosition> textPositions)
|
||||
throws IOException {
|
||||
if (textPositions != null && !textPositions.isEmpty()) {
|
||||
CapturedLine line = new CapturedLine();
|
||||
line.text = text;
|
||||
|
||||
float minX = Float.MAX_VALUE;
|
||||
float maxRight = 0f;
|
||||
float minY = Float.MAX_VALUE;
|
||||
float maxHeight = 0f;
|
||||
for (TextPosition p : textPositions) {
|
||||
float x = p.getXDirAdj();
|
||||
float y = p.getYDirAdj();
|
||||
float w = p.getWidthDirAdj();
|
||||
float h = p.getHeightDir();
|
||||
if (h == 0f) {
|
||||
// Workaround: some fonts report 0 height via TextPosition; fall back to
|
||||
// the nominal font size so downstream bboxes are never zero-height.
|
||||
h = p.getFontSizeInPt();
|
||||
}
|
||||
if (x < minX) minX = x;
|
||||
if (x + w > maxRight) maxRight = x + w;
|
||||
if (y < minY) minY = y;
|
||||
if (h > maxHeight) maxHeight = h;
|
||||
}
|
||||
line.x = minX;
|
||||
line.width = maxRight - minX;
|
||||
line.yTopDown = minY;
|
||||
line.height = maxHeight;
|
||||
lines.add(line);
|
||||
}
|
||||
super.writeString(text, textPositions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import io.github.pixee.security.BoundedLineReader;
|
||||
|
||||
@@ -202,7 +203,15 @@ public class ProcessExecutor {
|
||||
boolean useSemaphore = true;
|
||||
List<String> commandToRun = command;
|
||||
if (shouldUseUnoServerPool(command)) {
|
||||
unoLease = unoServerPool.acquireEndpoint();
|
||||
try {
|
||||
unoLease = unoServerPool.acquireEndpoint(timeoutDuration, TimeUnit.MINUTES);
|
||||
} catch (TimeoutException e) {
|
||||
throw new IOException(
|
||||
"All unoserver endpoints busy; request timed out after "
|
||||
+ timeoutDuration
|
||||
+ " minutes",
|
||||
e);
|
||||
}
|
||||
commandToRun = applyUnoServerEndpoint(command, unoLease.getEndpoint());
|
||||
useSemaphore = false;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package stirling.software.common.util;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
@@ -42,6 +45,27 @@ public class UnoServerPool {
|
||||
return new UnoServerLease(endpoints.get(index), index, this);
|
||||
}
|
||||
|
||||
/** Fail-fast variant; non-positive timeout falls back to unbounded acquire. */
|
||||
public UnoServerLease acquireEndpoint(long timeout, TimeUnit unit)
|
||||
throws InterruptedException, TimeoutException {
|
||||
if (endpoints.isEmpty()) {
|
||||
return new UnoServerLease(defaultEndpoint(), null, this);
|
||||
}
|
||||
if (timeout <= 0) {
|
||||
return acquireEndpoint();
|
||||
}
|
||||
|
||||
Integer index = availableIndices.poll(timeout, unit);
|
||||
if (index == null) {
|
||||
throw new TimeoutException(
|
||||
"Timed out waiting for a free unoserver endpoint after "
|
||||
+ timeout
|
||||
+ " "
|
||||
+ unit.name().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
return new UnoServerLease(endpoints.get(index), index, this);
|
||||
}
|
||||
|
||||
private void releaseEndpoint(Integer index) {
|
||||
if (index != null) {
|
||||
availableIndices.offer(index);
|
||||
|
||||
+18
-6
@@ -6,15 +6,18 @@ import java.util.List;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.security.RedactionArea;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.DeserializationFeature;
|
||||
import tools.jackson.databind.JavaType;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Spring property editor that decodes a JSON string into a typed {@link ArrayList}. Used to bind
|
||||
* complex list parameters (e.g. {@code List<RedactionArea>}, {@code List<EditTextOperation>}) from
|
||||
* multipart form fields, where Spring's default binding cannot deserialize a JSON array.
|
||||
*/
|
||||
@Slf4j
|
||||
public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
|
||||
public class StringToArrayListPropertyEditor<T> extends PropertyEditorSupport {
|
||||
|
||||
private final ObjectMapper objectMapper =
|
||||
JsonMapper.builder()
|
||||
@@ -22,6 +25,12 @@ public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.build();
|
||||
|
||||
private final Class<T> elementType;
|
||||
|
||||
public StringToArrayListPropertyEditor(Class<T> elementType) {
|
||||
this.elementType = elementType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAsText(String text) throws IllegalArgumentException {
|
||||
if (text == null || text.trim().isEmpty()) {
|
||||
@@ -29,8 +38,11 @@ public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
TypeReference<ArrayList<RedactionArea>> typeRef = new TypeReference<>() {};
|
||||
List<RedactionArea> list = objectMapper.readValue(text, typeRef);
|
||||
JavaType listType =
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(ArrayList.class, elementType);
|
||||
List<T> list = objectMapper.readValue(text, listType);
|
||||
setValue(list);
|
||||
} catch (Exception e) {
|
||||
log.error("Exception while converting {}", e);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EndpointConfigurationTest {
|
||||
|
||||
@Test
|
||||
void endpointKeyForUriExtractsSimpleKebab() {
|
||||
assertEquals(
|
||||
"remove-pages",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/general/remove-pages"));
|
||||
assertEquals(
|
||||
"compress-pdf",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/misc/compress-pdf"));
|
||||
assertEquals(
|
||||
"add-watermark",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/security/add-watermark"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointKeyForUriComposesConvertEndpoints() {
|
||||
assertEquals(
|
||||
"pdf-to-img", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/img"));
|
||||
assertEquals(
|
||||
"pdf-to-word", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/word"));
|
||||
assertEquals(
|
||||
"html-to-pdf", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/html/pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointKeyForUriReturnsNullForNonApiPaths() {
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri(null));
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri("/some-page"));
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri("/api/v1/general"));
|
||||
}
|
||||
}
|
||||
+129
-2
@@ -22,11 +22,13 @@ import org.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RequestCallback;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.ResponseExtractor;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import jakarta.servlet.ServletContext;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@@ -42,8 +44,19 @@ class InternalApiClientTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient().when(servletContext.getContextPath()).thenReturn("");
|
||||
client = newClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh client. Tests that use {@link org.mockito.Mockito#mockConstruction} to
|
||||
* intercept {@link RestTemplate} must call this from inside their {@code mockConstruction}
|
||||
* block, since the client now caches one RestTemplate per instance at construction time.
|
||||
*/
|
||||
private InternalApiClient newClient() {
|
||||
MockEnvironment environment = new MockEnvironment().withProperty("server.port", "8080");
|
||||
client = new InternalApiClient(servletContext, userService, tempFileManager, environment);
|
||||
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
return new InternalApiClient(
|
||||
servletContext, userService, tempFileManager, environment, applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -75,7 +88,10 @@ class InternalApiClientTest {
|
||||
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
|
||||
})) {
|
||||
|
||||
ResponseEntity<Resource> response = client.post("/api/v1/general/merge-pdfs", body);
|
||||
// Reconstruct the client so its cached RestTemplate is the mocked one.
|
||||
InternalApiClient mockedClient = newClient();
|
||||
ResponseEntity<Resource> response =
|
||||
mockedClient.post("/api/v1/general/merge-pdfs", body);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
@@ -86,12 +102,123 @@ class InternalApiClientTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postWrapsSocketTimeoutAsInternalApiTimeoutException() {
|
||||
// Simulates the read-timeout case: RestTemplate wraps SocketTimeoutException in
|
||||
// ResourceAccessException when the underlying socket times out. The client must repackage
|
||||
// that into a typed timeout exception that carries the failing endpoint and the configured
|
||||
// read timeout, so the workflow layer can surface a clean "tool didn't respond" message
|
||||
// to the user.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("fileInput", namedResource("input.pdf", "data"));
|
||||
|
||||
try (var ignored =
|
||||
mockConstruction(
|
||||
RestTemplate.class,
|
||||
(rt, ctx) -> {
|
||||
when(rt.httpEntityCallback(any(), eq(Resource.class)))
|
||||
.thenAnswer(inv -> (RequestCallback) req -> {});
|
||||
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
|
||||
.thenThrow(
|
||||
new ResourceAccessException(
|
||||
"I/O error on POST request: Read timed out",
|
||||
new java.net.SocketTimeoutException(
|
||||
"Read timed out")));
|
||||
})) {
|
||||
|
||||
InternalApiClient mockedClient = newClient();
|
||||
InternalApiTimeoutException thrown =
|
||||
assertThrows(
|
||||
InternalApiTimeoutException.class,
|
||||
() -> mockedClient.post("/api/v1/general/merge-pdfs", body));
|
||||
|
||||
assertEquals("/api/v1/general/merge-pdfs", thrown.getEndpointPath());
|
||||
assertNotNull(thrown.getReadTimeout());
|
||||
assertTrue(thrown.getMessage().contains("/api/v1/general/merge-pdfs"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postRethrowsNonTimeoutResourceAccessExceptionAsIs() {
|
||||
// ResourceAccessException covers more than just timeouts (e.g. connection refused, DNS
|
||||
// failure). Only SocketTimeoutException-rooted failures are timeouts; everything else
|
||||
// must propagate so the upstream generic handler can label it correctly instead of lying
|
||||
// about a "tool didn't respond" timeout.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("fileInput", namedResource("input.pdf", "data"));
|
||||
|
||||
try (var ignored =
|
||||
mockConstruction(
|
||||
RestTemplate.class,
|
||||
(rt, ctx) -> {
|
||||
when(rt.httpEntityCallback(any(), eq(Resource.class)))
|
||||
.thenAnswer(inv -> (RequestCallback) req -> {});
|
||||
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
|
||||
.thenThrow(
|
||||
new ResourceAccessException(
|
||||
"I/O error on POST request: Connection refused",
|
||||
new java.net.ConnectException(
|
||||
"Connection refused")));
|
||||
})) {
|
||||
|
||||
InternalApiClient mockedClient = newClient();
|
||||
assertThrows(
|
||||
ResourceAccessException.class,
|
||||
() -> mockedClient.post("/api/v1/general/merge-pdfs", body));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postRejectsDisallowedPath() {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
assertThrows(SecurityException.class, () -> client.post("/api/v1/admin/settings", body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postRejectsAiEndpointsOutsideToolsSubnamespace() {
|
||||
// /api/v1/ai/orchestrate and other non-tool AI endpoints are not internally
|
||||
// dispatchable. Only /api/v1/ai/tools/* and the general/misc/security/convert/filter
|
||||
// namespaces are on the allowlist — letting a plan step re-enter /orchestrate would
|
||||
// introduce recursion risk.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
assertThrows(SecurityException.class, () -> client.post("/api/v1/ai/orchestrate", body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postAcceptsAiToolsSubnamespace() throws Exception {
|
||||
// Agent tool paths like /api/v1/ai/tools/pdf-comment-agent are on the allowlist and
|
||||
// should be dispatchable by the orchestrator's plan executor.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("fileInput", namedResource("input.pdf", "data"));
|
||||
|
||||
Path tempPath = Files.createTempFile("internal-api-ai-tools-test", ".tmp");
|
||||
TempFile tempFile = mock(TempFile.class);
|
||||
when(tempFile.getPath()).thenReturn(tempPath);
|
||||
when(tempFile.getFile()).thenReturn(tempPath.toFile());
|
||||
when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile);
|
||||
|
||||
try (var ignored =
|
||||
mockConstruction(
|
||||
RestTemplate.class,
|
||||
(rt, ctx) -> {
|
||||
when(rt.httpEntityCallback(any(), eq(Resource.class)))
|
||||
.thenReturn((RequestCallback) req -> {});
|
||||
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
|
||||
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
|
||||
})) {
|
||||
|
||||
// Reconstruct the client so its cached RestTemplate is the mocked one.
|
||||
InternalApiClient mockedClient = newClient();
|
||||
ResponseEntity<Resource> response =
|
||||
mockedClient.post("/api/v1/ai/tools/pdf-comment-agent", body);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
} finally {
|
||||
Files.deleteIfExists(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postRejectsPathTraversal() {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.api.comments.AnnotationLocation;
|
||||
import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
|
||||
class PdfAnnotationServiceTest {
|
||||
|
||||
private PdfAnnotationService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PdfAnnotationService();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addStickyNotesPlacesOneAnnotationPerValidSpec() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "First comment", "alice", null),
|
||||
spec(1, 100f, 650f, "Second comment", null, "Second"));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(2, applied);
|
||||
byte[] saved = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
assertEquals(1, textAnnotations(reloaded.getPage(0).getAnnotations()).size());
|
||||
assertEquals(1, textAnnotations(reloaded.getPage(1).getAnnotations()).size());
|
||||
|
||||
PDAnnotationText first =
|
||||
textAnnotations(reloaded.getPage(0).getAnnotations()).get(0);
|
||||
assertEquals("First comment", first.getContents());
|
||||
assertEquals("alice", first.getTitlePopup(), "author override propagates");
|
||||
assertNotNull(first.getSubject(), "subject falls back to default when null");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithBlankText() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "Valid", null, null),
|
||||
spec(0, 72f, 680f, " ", null, null));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Blank-text spec must be skipped");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithOutOfRangePageIndex() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "OK", null, null),
|
||||
spec(99, 72f, 700f, "Too far", null, null),
|
||||
spec(-1, 72f, 700f, "Negative", null, null));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Only the in-range spec should be applied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesNullAndEmptySpecList() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
assertEquals(0, service.addStickyNotes(doc, null));
|
||||
assertEquals(0, service.addStickyNotes(doc, List.of()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithNonPositiveDimensions() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
StickyNoteSpec zeroWidth =
|
||||
new StickyNoteSpec(
|
||||
new AnnotationLocation(0, 72f, 700f, 0f, 20f),
|
||||
"Zero width",
|
||||
null,
|
||||
null);
|
||||
StickyNoteSpec negativeHeight =
|
||||
new StickyNoteSpec(
|
||||
new AnnotationLocation(0, 72f, 680f, 20f, -5f),
|
||||
"Negative height",
|
||||
null,
|
||||
null);
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(spec(0, 72f, 660f, "OK", null, null), zeroWidth, negativeHeight);
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Only the positively-sized spec should be applied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithOverlongText() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
String overlong = "x".repeat(100_001);
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "Short", null, null),
|
||||
spec(0, 72f, 680f, overlong, null, null));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Overlong-text spec must be skipped");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesDefaultAuthorAndSubjectWhenAbsent() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
service.addStickyNote(doc, spec(0, 72f, 700f, "No author given", null, null));
|
||||
|
||||
byte[] saved = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAnnotationText annot =
|
||||
textAnnotations(reloaded.getPage(0).getAnnotations()).get(0);
|
||||
assertTrue(
|
||||
annot.getTitlePopup() != null && !annot.getTitlePopup().isBlank(),
|
||||
"Default author should be applied");
|
||||
assertTrue(
|
||||
annot.getSubject() != null && !annot.getSubject().isBlank(),
|
||||
"Default subject should be applied");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static StickyNoteSpec spec(
|
||||
int page, float x, float y, String text, String author, String subject) {
|
||||
return new StickyNoteSpec(
|
||||
new AnnotationLocation(page, x, y, 20f, 20f), text, author, subject);
|
||||
}
|
||||
|
||||
private static byte[] twoPagePdfBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument doc) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static List<PDAnnotationText> textAnnotations(List<PDAnnotation> annotations) {
|
||||
List<PDAnnotationText> out = new ArrayList<>();
|
||||
for (PDAnnotation a : annotations) {
|
||||
if (a instanceof PDAnnotationText t) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.util.PdfTextLocator.MatchedBox;
|
||||
|
||||
class PdfTextLocatorTest {
|
||||
|
||||
private final PdfTextLocator locator = new PdfTextLocator();
|
||||
|
||||
@Test
|
||||
void findsLineContainingNeedleAndReturnsUserSpaceBox() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Revenue: $215,000", "Expenses: $120,000"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "215000");
|
||||
assertThat(match).isPresent();
|
||||
MatchedBox box = match.get();
|
||||
// Line was drawn at y=720 in user-space (bottom-left origin); locator
|
||||
// should return a bbox close to that height band with non-zero width.
|
||||
assertThat(box.width()).isGreaterThan(0f);
|
||||
assertThat(box.height()).isGreaterThan(0f);
|
||||
assertThat(box.y()).isBetween(700f, 740f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchIsCaseAndPunctuationInsensitive() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Total Revenue.", "Q4 summary"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "total revenue");
|
||||
assertThat(match).isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyWhenNeedleNotFound() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Nothing to see here"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "not-on-this-page");
|
||||
assertThat(match).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyForBlankNeedle() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Any text"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertThat(locator.findOnPage(doc, 0, "")).isEmpty();
|
||||
assertThat(locator.findOnPage(doc, 0, " ")).isEmpty();
|
||||
assertThat(locator.findOnPage(doc, 0, null)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyForOutOfRangePage() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Single page"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertThat(locator.findOnPage(doc, -1, "single")).isEmpty();
|
||||
assertThat(locator.findOnPage(doc, 99, "single")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] pdfWithLines(String[] lines) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.setNonStrokingColor(Color.BLACK);
|
||||
float y = 720f;
|
||||
for (String line : lines) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(72f, y);
|
||||
cs.showText(line);
|
||||
cs.endText();
|
||||
y -= 20f;
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -173,6 +174,70 @@ public class UnoServerPoolTest {
|
||||
assertEquals(port1, port2, "Should reuse the same endpoint after release");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAcquireWithTimeoutFailsFast() throws InterruptedException {
|
||||
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints =
|
||||
createEndpoints(1);
|
||||
UnoServerPool pool = new UnoServerPool(endpoints);
|
||||
|
||||
UnoServerPool.UnoServerLease held = pool.acquireEndpoint();
|
||||
|
||||
long start = System.nanoTime();
|
||||
assertThrows(
|
||||
TimeoutException.class,
|
||||
() -> pool.acquireEndpoint(100, TimeUnit.MILLISECONDS),
|
||||
"Should time out when no endpoint available");
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
|
||||
assertTrue(
|
||||
elapsedMs >= 100 && elapsedMs < 1000,
|
||||
"Should fail-fast after roughly the timeout, got " + elapsedMs + "ms");
|
||||
|
||||
held.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAcquireWithTimeoutSucceedsWhenAvailable()
|
||||
throws InterruptedException, TimeoutException {
|
||||
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints =
|
||||
createEndpoints(1);
|
||||
UnoServerPool pool = new UnoServerPool(endpoints);
|
||||
|
||||
try (UnoServerPool.UnoServerLease lease = pool.acquireEndpoint(1, TimeUnit.SECONDS)) {
|
||||
assertNotNull(lease);
|
||||
assertEquals(2003, lease.getEndpoint().getPort());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAcquireWithZeroTimeoutBlocksUnbounded() throws InterruptedException {
|
||||
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints =
|
||||
createEndpoints(1);
|
||||
UnoServerPool pool = new UnoServerPool(endpoints);
|
||||
UnoServerPool.UnoServerLease held = pool.acquireEndpoint();
|
||||
|
||||
AtomicInteger acquired = new AtomicInteger(0);
|
||||
Thread t =
|
||||
Thread.ofVirtual()
|
||||
.start(
|
||||
() -> {
|
||||
try {
|
||||
UnoServerPool.UnoServerLease lease =
|
||||
pool.acquireEndpoint(0, TimeUnit.MILLISECONDS);
|
||||
acquired.incrementAndGet();
|
||||
lease.close();
|
||||
} catch (Exception e) {
|
||||
fail("unexpected: " + e);
|
||||
}
|
||||
});
|
||||
|
||||
Thread.sleep(150);
|
||||
assertEquals(0, acquired.get(), "Should still be blocked");
|
||||
held.close();
|
||||
t.join(1000);
|
||||
assertEquals(1, acquired.get(), "Should acquire after release");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHostLocationAndProtocol() throws InterruptedException {
|
||||
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints = new ArrayList<>();
|
||||
|
||||
+2
-2
@@ -11,11 +11,11 @@ import stirling.software.common.model.api.security.RedactionArea;
|
||||
|
||||
class StringToArrayListPropertyEditorTest {
|
||||
|
||||
private StringToArrayListPropertyEditor editor;
|
||||
private StringToArrayListPropertyEditor<RedactionArea> editor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
editor = new StringToArrayListPropertyEditor();
|
||||
editor = new StringToArrayListPropertyEditor<>(RedactionArea.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -65,7 +65,7 @@ dependencies {
|
||||
exclude group: 'com.fasterxml.jackson.jaxrs'
|
||||
exclude group: 'com.fasterxml.jackson.module', module: 'jackson-module-jaxb-annotations'
|
||||
}
|
||||
implementation 'commons-io:commons-io:2.21.0'
|
||||
implementation 'commons-io:commons-io:2.22.0'
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
||||
implementation 'io.micrometer:micrometer-core'
|
||||
|
||||
@@ -21,28 +21,7 @@ public class EndpointInterceptor implements HandlerInterceptor {
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
String requestURI = request.getRequestURI();
|
||||
boolean isEnabled;
|
||||
|
||||
// Extract the specific endpoint name (e.g: /api/v1/general/remove-pages -> remove-pages)
|
||||
if (requestURI.contains("/api/v1") && requestURI.split("/").length > 4) {
|
||||
|
||||
String[] requestURIParts = requestURI.split("/");
|
||||
String requestEndpoint;
|
||||
|
||||
// Endpoint: /api/v1/convert/pdf/img becomes pdf-to-img
|
||||
if ("convert".equals(requestURIParts[3]) && requestURIParts.length > 5) {
|
||||
requestEndpoint = requestURIParts[4] + "-to-" + requestURIParts[5];
|
||||
} else {
|
||||
requestEndpoint = requestURIParts[4];
|
||||
}
|
||||
|
||||
log.debug("Request endpoint: {}", requestEndpoint);
|
||||
isEnabled = endpointConfiguration.isEndpointEnabled(requestEndpoint);
|
||||
log.debug("Is endpoint enabled: {}", isEnabled);
|
||||
} else {
|
||||
isEnabled = endpointConfiguration.isEndpointEnabled(requestURI);
|
||||
}
|
||||
|
||||
boolean isEnabled = endpointConfiguration.isEndpointEnabledForUri(requestURI);
|
||||
if (!isEnabled) {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
import org.springframework.web.bind.annotation.InitBinder;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.general.EditTextRequest;
|
||||
import stirling.software.SPDF.model.json.PdfJsonDocument;
|
||||
import stirling.software.SPDF.model.json.PdfJsonPage;
|
||||
import stirling.software.SPDF.model.json.PdfJsonTextElement;
|
||||
import stirling.software.SPDF.service.PdfJsonConversionService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.model.api.general.EditTextOperation;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.common.util.propertyeditor.StringToArrayListPropertyEditor;
|
||||
|
||||
/**
|
||||
* Find/replace text editing for PDFs. Round-trips through {@link PdfJsonConversionService}: the
|
||||
* input PDF is parsed into the editable JSON model, find/replace operations are applied to the
|
||||
* {@code text} field of each text element, and the mutated model is rebuilt into a PDF.
|
||||
*
|
||||
* <p>Matching joins all text elements on a page into a single string before searching, so find
|
||||
* strings can span multiple visual runs (titles split per word, kerning-broken phrases, etc.).
|
||||
*
|
||||
* <p>For cross-element matches the entire replacement is written into the first matched element,
|
||||
* any intermediate elements are emptied, and the suffix of the last matched element is preserved.
|
||||
* This anchors the new text at the leftmost match position and lets the font lay it out as one run,
|
||||
* which is more visually reliable than redistributing words to the original per-element X positions
|
||||
* (those positions are calibrated to the original word widths and rarely match the replacement
|
||||
* widths). Centered or tracked layouts will become left-aligned at the original first-word position
|
||||
* when their text is replaced - the trade-off is accepted in favour of avoiding glyph overlaps and
|
||||
* gaps from variable-width word distribution.
|
||||
*/
|
||||
@Slf4j
|
||||
@GeneralApi
|
||||
@RequiredArgsConstructor
|
||||
public class EditTextController {
|
||||
|
||||
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
|
||||
|
||||
private final PdfJsonConversionService pdfJsonConversionService;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@InitBinder
|
||||
public void initBinder(WebDataBinder binder) {
|
||||
binder.registerCustomEditor(
|
||||
List.class,
|
||||
"edits",
|
||||
new StringToArrayListPropertyEditor<>(EditTextOperation.class));
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/edit-text")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Edit text in a PDF via find and replace",
|
||||
description =
|
||||
"Applies an ordered list of find/replace operations to the text in a PDF and"
|
||||
+ " returns the edited PDF. Useful for find-and-replace, bulk renames"
|
||||
+ " (e.g. updating a company name throughout a document), and copy"
|
||||
+ " editing where the AI agent has identified specific replacements."
|
||||
+ " Matching is performed against the joined text of each page, so"
|
||||
+ " find strings can span multiple visual runs (titles split per word,"
|
||||
+ " kerning-broken phrases). Cross-element matches are written as a"
|
||||
+ " single replacement run anchored at the leftmost matched position;"
|
||||
+ " centered or tracked text may shift left when its content changes."
|
||||
+ " Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<Resource> editText(@ModelAttribute EditTextRequest request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
if (inputFile == null) {
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
List<EditTextOperation> edits = request.getEdits();
|
||||
if (edits == null || edits.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.editText.no.edits",
|
||||
"No find/replace operations provided for text editing");
|
||||
}
|
||||
for (EditTextOperation edit : edits) {
|
||||
if (edit == null || edit.getFind() == null || edit.getFind().isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.editText.empty.find", "Each edit must have a non-empty find string");
|
||||
}
|
||||
}
|
||||
|
||||
boolean wholeWordSearch = Boolean.TRUE.equals(request.getWholeWordSearch());
|
||||
List<CompiledEdit> compiledEdits = compileEdits(edits, wholeWordSearch);
|
||||
|
||||
PdfJsonDocument document = pdfJsonConversionService.convertPdfToJsonDocument(inputFile);
|
||||
Set<Integer> pageFilter = resolvePageFilter(request, document);
|
||||
int modifiedSpans = applyEdits(document, compiledEdits, pageFilter);
|
||||
log.info(
|
||||
"edit-text: modified {} text span(s) using {} edit(s) on {} page(s)",
|
||||
modifiedSpans,
|
||||
compiledEdits.size(),
|
||||
pageFilter == null
|
||||
? document.getPages() == null ? 0 : document.getPages().size()
|
||||
: pageFilter.size());
|
||||
|
||||
String docName = buildOutputFilename(inputFile);
|
||||
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try (OutputStream os = Files.newOutputStream(tempOut.getPath())) {
|
||||
pdfJsonConversionService.convertJsonToPdf(document, os);
|
||||
} catch (Exception e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
}
|
||||
return WebResponseUtils.pdfFileToWebResponse(tempOut, docName);
|
||||
}
|
||||
|
||||
private List<CompiledEdit> compileEdits(
|
||||
List<EditTextOperation> edits, boolean wholeWordSearch) {
|
||||
return edits.stream().map(edit -> compileEdit(edit, wholeWordSearch)).toList();
|
||||
}
|
||||
|
||||
private CompiledEdit compileEdit(EditTextOperation edit, boolean wholeWordSearch) {
|
||||
// Always treat the user-supplied find string as a literal: Pattern.quote escapes any
|
||||
// regex metacharacters, so the constructed pattern can only ever do a literal match
|
||||
// (optionally bounded by our own word-boundary anchors). This rules out catastrophic
|
||||
// backtracking from a malicious find string.
|
||||
String findRaw = edit.getFind();
|
||||
String replacement = Objects.toString(edit.getReplace(), "");
|
||||
String regex = Pattern.quote(findRaw);
|
||||
if (wholeWordSearch) {
|
||||
// Use lookarounds rather than \b so the bound works even when the find string starts
|
||||
// or ends with a non-word character (e.g. "-foo" or "$id"). \b only fires at a
|
||||
// word/non-word transition, which would never trigger for a find that starts with a
|
||||
// non-word char and was preceded by another non-word char in the source text.
|
||||
regex = "(?<!\\w)(?:" + regex + ")(?!\\w)";
|
||||
}
|
||||
Pattern pattern = Pattern.compile(regex);
|
||||
String safeReplacement = Matcher.quoteReplacement(replacement);
|
||||
return new CompiledEdit(pattern, safeReplacement);
|
||||
}
|
||||
|
||||
private Set<Integer> resolvePageFilter(EditTextRequest request, PdfJsonDocument document) {
|
||||
String pageNumbers = request.getPageNumbers();
|
||||
int totalPages = document.getPages() == null ? 0 : document.getPages().size();
|
||||
if (totalPages == 0) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
if (pageNumbers == null || pageNumbers.isBlank() || "all".equalsIgnoreCase(pageNumbers)) {
|
||||
return null;
|
||||
}
|
||||
List<Integer> pages = GeneralUtils.parsePageList(pageNumbers, totalPages, true);
|
||||
return new HashSet<>(pages);
|
||||
}
|
||||
|
||||
private int applyEdits(
|
||||
PdfJsonDocument document, List<CompiledEdit> edits, Set<Integer> pageFilter) {
|
||||
List<PdfJsonPage> pages = document.getPages();
|
||||
if (pages == null) {
|
||||
return 0;
|
||||
}
|
||||
int modifiedSpans = 0;
|
||||
// The page filter is built from parsePageList against pages.size(), so it returns 1-based
|
||||
// positional page numbers. Match against the same positional numbering here rather than
|
||||
// the per-page pageNumber field, which can become non-sequential after split/merge round
|
||||
// trips and would misalign the filter.
|
||||
for (int i = 0; i < pages.size(); i++) {
|
||||
int positionalPageNumber = i + 1;
|
||||
if (pageFilter != null && !pageFilter.contains(positionalPageNumber)) {
|
||||
continue;
|
||||
}
|
||||
modifiedSpans += applyEditsToPage(pages.get(i), edits);
|
||||
}
|
||||
return modifiedSpans;
|
||||
}
|
||||
|
||||
private int applyEditsToPage(PdfJsonPage page, List<CompiledEdit> edits) {
|
||||
List<PdfJsonTextElement> elements = page.getTextElements();
|
||||
if (elements == null || elements.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
Set<Integer> modifiedIndices = new HashSet<>();
|
||||
for (CompiledEdit edit : edits) {
|
||||
applyEditToPage(elements, edit, modifiedIndices);
|
||||
}
|
||||
for (Integer index : modifiedIndices) {
|
||||
// Char codes were captured for the original glyph sequence; clear them so the rebuild
|
||||
// re-encodes from the new text via the font.
|
||||
elements.get(index).setCharCodes(null);
|
||||
}
|
||||
return modifiedIndices.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single edit across the page by matching against the concatenation of all element
|
||||
* texts, then writing the replacement back into the originating element(s).
|
||||
*/
|
||||
private void applyEditToPage(
|
||||
List<PdfJsonTextElement> elements, CompiledEdit edit, Set<Integer> modifiedIndices) {
|
||||
StringBuilder joined = new StringBuilder();
|
||||
int[] starts = new int[elements.size()];
|
||||
int[] ends = new int[elements.size()];
|
||||
for (int i = 0; i < elements.size(); i++) {
|
||||
starts[i] = joined.length();
|
||||
String text = elements.get(i).getText();
|
||||
if (text != null) {
|
||||
joined.append(text);
|
||||
}
|
||||
ends[i] = joined.length();
|
||||
}
|
||||
|
||||
Matcher matcher = edit.pattern().matcher(joined);
|
||||
List<MatchSpan> spans = new ArrayList<>();
|
||||
StringBuffer interpolation = new StringBuffer();
|
||||
int previousAppendPosition = 0;
|
||||
while (matcher.find()) {
|
||||
if (matcher.start() == matcher.end()) {
|
||||
// Skip zero-length matches (e.g. /a*/ on empty input) — they cannot be applied.
|
||||
continue;
|
||||
}
|
||||
int sizeBefore = interpolation.length();
|
||||
matcher.appendReplacement(interpolation, edit.replacement());
|
||||
int prefixLength = matcher.start() - previousAppendPosition;
|
||||
String actualReplacement =
|
||||
interpolation.substring(sizeBefore + prefixLength, interpolation.length());
|
||||
spans.add(new MatchSpan(matcher.start(), matcher.end(), actualReplacement));
|
||||
previousAppendPosition = matcher.end();
|
||||
}
|
||||
|
||||
// Apply right-to-left so earlier match positions stay valid as we mutate elements.
|
||||
for (int i = spans.size() - 1; i >= 0; i--) {
|
||||
MatchSpan span = spans.get(i);
|
||||
int firstElement = findElementForCharIndex(starts, ends, span.start());
|
||||
int lastElement = findElementForCharIndex(starts, ends, span.end() - 1);
|
||||
if (firstElement < 0 || lastElement < 0) {
|
||||
continue;
|
||||
}
|
||||
applyMatchToElements(
|
||||
elements, starts, span, firstElement, lastElement, modifiedIndices);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element whose text covers the character at {@code charIndex} in the joined string.
|
||||
* Returns -1 if no element covers that index (which should not happen for valid match spans).
|
||||
*/
|
||||
private static int findElementForCharIndex(int[] starts, int[] ends, int charIndex) {
|
||||
for (int i = 0; i < starts.length; i++) {
|
||||
if (starts[i] <= charIndex && charIndex < ends[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void applyMatchToElements(
|
||||
List<PdfJsonTextElement> elements,
|
||||
int[] starts,
|
||||
MatchSpan span,
|
||||
int firstElement,
|
||||
int lastElement,
|
||||
Set<Integer> modifiedIndices) {
|
||||
if (firstElement == lastElement) {
|
||||
PdfJsonTextElement element = elements.get(firstElement);
|
||||
String text = nullToEmpty(element.getText());
|
||||
int matchStartInElement = span.start() - starts[firstElement];
|
||||
int matchEndInElement = span.end() - starts[firstElement];
|
||||
element.setText(
|
||||
text.substring(0, matchStartInElement)
|
||||
+ span.replacement()
|
||||
+ text.substring(matchEndInElement));
|
||||
modifiedIndices.add(firstElement);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cross-element match: write the whole replacement into the first matched element, empty
|
||||
// any intermediate elements, and keep only the suffix of the last matched element. The
|
||||
// JSON->PDF rebuild concatenates per-token text, so the font lays out the replacement as
|
||||
// one continuous run anchored at the first element's X position.
|
||||
String firstText = nullToEmpty(elements.get(firstElement).getText());
|
||||
int firstSplit = span.start() - starts[firstElement];
|
||||
elements.get(firstElement).setText(firstText.substring(0, firstSplit) + span.replacement());
|
||||
modifiedIndices.add(firstElement);
|
||||
|
||||
for (int mid = firstElement + 1; mid < lastElement; mid++) {
|
||||
elements.get(mid).setText("");
|
||||
modifiedIndices.add(mid);
|
||||
}
|
||||
|
||||
String lastText = nullToEmpty(elements.get(lastElement).getText());
|
||||
int lastSplit = span.end() - starts[lastElement];
|
||||
elements.get(lastElement).setText(lastText.substring(lastSplit));
|
||||
modifiedIndices.add(lastElement);
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String value) {
|
||||
return value != null ? value : "";
|
||||
}
|
||||
|
||||
private String buildOutputFilename(MultipartFile inputFile) {
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(originalName))
|
||||
.replaceFirst("")
|
||||
: "document";
|
||||
return baseName + "_edited.pdf";
|
||||
}
|
||||
|
||||
private record CompiledEdit(Pattern pattern, String replacement) {}
|
||||
|
||||
private record MatchSpan(int start, int end, String replacement) {}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.misc.AddCommentsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.model.api.comments.AnnotationLocation;
|
||||
import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfAnnotationService;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PdfTextLocator;
|
||||
import stirling.software.common.util.PdfTextLocator.MatchedBox;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Deterministic Java tool: add sticky-note comments to a PDF at caller-supplied positions.
|
||||
* Composable primitive used by AI agents (that generate comment specs) and by any other caller —
|
||||
* Automate workflows, scripts, unit tests — that has comment positions and text in hand.
|
||||
*
|
||||
* <p>Each {@code CommentSpec} element accepts either absolute coordinates ({@code x, y, width,
|
||||
* height}) or an {@code anchorText} hint. When {@code anchorText} is present, the tool scans the
|
||||
* target page, finds the first line whose text contains the needle (tolerant match — case and
|
||||
* punctuation insensitive), and anchors the sticky-note icon at that line's bounding box. Falls
|
||||
* back to the supplied coordinates when no match is found.
|
||||
*
|
||||
* <p>Pairs with {@link PdfAnnotationService} (annotation creation) and {@link PdfTextLocator}
|
||||
* (anchor resolution).
|
||||
*/
|
||||
@Slf4j
|
||||
@MiscApi
|
||||
@RequiredArgsConstructor
|
||||
public class AddCommentsController {
|
||||
|
||||
/** Sticky-note icon size in PDF user-space units. Matches the agents' default. */
|
||||
private static final float ANCHOR_ICON_SIZE = 20f;
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final PdfAnnotationService pdfAnnotationService;
|
||||
private final PdfTextLocator pdfTextLocator;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@AutoJobPostMapping(value = "/add-comments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Add sticky-note comments to a PDF at specified positions or anchored text",
|
||||
description =
|
||||
"Attaches PDF Text (sticky-note) annotations to the document."
|
||||
+ " Each CommentSpec can either supply absolute coordinates or an"
|
||||
+ " `anchorText` hint; when provided, the tool locates the first matching"
|
||||
+ " line on the target page and anchors the icon there (falling back to"
|
||||
+ " the coordinates if no match). Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<Resource> addComments(@ModelAttribute AddCommentsRequest request)
|
||||
throws IOException {
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "fileInput is required");
|
||||
}
|
||||
String commentsJson = request.getComments();
|
||||
if (commentsJson == null || commentsJson.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "comments JSON is required");
|
||||
}
|
||||
|
||||
List<CommentSpecDto> dtos;
|
||||
try {
|
||||
dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {});
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects");
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
List<StickyNoteSpec> specs = resolveSpecs(document, dtos);
|
||||
pdfAnnotationService.addStickyNotes(document, specs);
|
||||
|
||||
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try {
|
||||
document.save(tempOut.getFile());
|
||||
} catch (IOException e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
}
|
||||
return WebResponseUtils.pdfFileToWebResponse(
|
||||
tempOut,
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_commented.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the wire DTOs into {@link StickyNoteSpec}s, resolving any {@code anchorText} hints
|
||||
* against the PDF. Each spec is resolved independently so a miss falls back locally without
|
||||
* affecting other specs.
|
||||
*/
|
||||
private List<StickyNoteSpec> resolveSpecs(PDDocument document, List<CommentSpecDto> dtos) {
|
||||
List<StickyNoteSpec> specs = new ArrayList<>(dtos.size());
|
||||
for (CommentSpecDto dto : dtos) {
|
||||
specs.add(toSpec(document, dto));
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
private StickyNoteSpec toSpec(PDDocument document, CommentSpecDto d) {
|
||||
AnnotationLocation location = resolveLocation(document, d);
|
||||
return new StickyNoteSpec(location, d.text, d.author, d.subject);
|
||||
}
|
||||
|
||||
private AnnotationLocation resolveLocation(PDDocument document, CommentSpecDto d) {
|
||||
if (d.anchorText == null || d.anchorText.isBlank()) {
|
||||
return new AnnotationLocation(d.pageIndex, d.x, d.y, d.width, d.height);
|
||||
}
|
||||
Optional<MatchedBox> match = pdfTextLocator.findOnPage(document, d.pageIndex, d.anchorText);
|
||||
if (match.isEmpty()) {
|
||||
log.debug(
|
||||
"add-comments: no match for anchorText {!r} on page {}; using fallback coords",
|
||||
d.anchorText,
|
||||
d.pageIndex);
|
||||
return new AnnotationLocation(d.pageIndex, d.x, d.y, d.width, d.height);
|
||||
}
|
||||
MatchedBox box = match.get();
|
||||
// Anchor the icon at the top-left of the matched line, matching the convention used by
|
||||
// PdfCommentAgentOrchestrator for its chunk-based placement.
|
||||
float iconX = box.x();
|
||||
float iconY = box.y() + box.height() - ANCHOR_ICON_SIZE;
|
||||
return new AnnotationLocation(
|
||||
d.pageIndex, iconX, iconY, ANCHOR_ICON_SIZE, ANCHOR_ICON_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-format DTO for a single element in the {@code comments} JSON array. Flat record-like
|
||||
* shape keeps the JSON simple for humans, AI-engine plan parameters, and Automate steps alike.
|
||||
*
|
||||
* <p>{@code anchorText} is optional. When present, the server locates the first line on {@code
|
||||
* pageIndex} containing that text (tolerant match) and places the icon there; the {@code
|
||||
* x/y/width/height} act as fallback when no match is found.
|
||||
*/
|
||||
private static final class CommentSpecDto {
|
||||
public int pageIndex;
|
||||
public float x;
|
||||
public float y;
|
||||
public float width;
|
||||
public float height;
|
||||
public String text;
|
||||
public String author;
|
||||
public String subject;
|
||||
public String anchorText;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -96,7 +96,9 @@ public class RedactController {
|
||||
@InitBinder
|
||||
public void initBinder(WebDataBinder binder) {
|
||||
binder.registerCustomEditor(
|
||||
List.class, "redactions", new StringToArrayListPropertyEditor());
|
||||
List.class,
|
||||
"redactions",
|
||||
new StringToArrayListPropertyEditor<>(RedactionArea.class));
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/redact", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
|
||||
@@ -682,7 +682,7 @@ public class GlobalExceptionHandler {
|
||||
*
|
||||
* @param ex the MaxUploadSizeExceededException
|
||||
* @param request the HTTP servlet request
|
||||
* @return ProblemDetail with HTTP 413 PAYLOAD_TOO_LARGE
|
||||
* @return ProblemDetail with HTTP 413 CONTENT_TOO_LARGE
|
||||
*/
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public ResponseEntity<ProblemDetail> handleMaxUploadSize(
|
||||
@@ -706,7 +706,7 @@ public class GlobalExceptionHandler {
|
||||
getLocalizedMessage("error.fileTooLarge.title", ErrorTitles.FILE_TOO_LARGE_DEFAULT);
|
||||
|
||||
ProblemDetail problemDetail =
|
||||
createBaseProblemDetail(HttpStatus.PAYLOAD_TOO_LARGE, message, request);
|
||||
createBaseProblemDetail(HttpStatus.CONTENT_TOO_LARGE, message, request);
|
||||
problemDetail.setType(URI.create(ErrorTypes.FILE_TOO_LARGE));
|
||||
problemDetail.setTitle(title);
|
||||
problemDetail.setProperty("title", title); // Ensure serialization
|
||||
@@ -724,7 +724,7 @@ public class GlobalExceptionHandler {
|
||||
problemDetail.setProperty(
|
||||
"actionRequired", "Reduce the file size to be within the upload limit.");
|
||||
|
||||
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
|
||||
return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE)
|
||||
.contentType(PROBLEM_JSON)
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package stirling.software.SPDF.model.api.general;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.SPDF.model.api.PDFWithPageNums;
|
||||
import stirling.software.common.model.api.general.EditTextOperation;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class EditTextRequest extends PDFWithPageNums {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Ordered list of find/replace operations. Each replaces every occurrence on"
|
||||
+ " the selected pages, in order; later operations see the result of"
|
||||
+ " earlier ones (so 'foo'->'foos' then 'foos'->'bars' turns 'foo'"
|
||||
+ " into 'bars').",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<EditTextOperation> edits;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Whether matches must be whole words (boundaries determined by non-word"
|
||||
+ " characters)",
|
||||
defaultValue = "false")
|
||||
private Boolean wholeWordSearch;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package stirling.software.SPDF.model.api.misc;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /api/v1/misc/add-comments}.
|
||||
*
|
||||
* <p>The {@code comments} field is a JSON-encoded array of {@code CommentSpec} objects rather than
|
||||
* a nested multipart part so the endpoint stays compatible with {@code InternalApiClient}'s flat
|
||||
* multipart form body (orchestrator plan dispatch). Jackson parses it controller-side.
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AddCommentsRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"JSON array of comment specs. Each element has: {pageIndex, x, y, width,"
|
||||
+ " height, text, author?, subject?}. Coordinates are PDF user-space with"
|
||||
+ " origin at the page's bottom-left.",
|
||||
example =
|
||||
"[{\"pageIndex\":0,\"x\":72,\"y\":720,\"width\":20,\"height\":20,"
|
||||
+ "\"text\":\"Check this paragraph\",\"author\":\"Reviewer\","
|
||||
+ "\"subject\":\"Unclear wording\"}]",
|
||||
requiredMode = RequiredMode.REQUIRED)
|
||||
private String comments;
|
||||
}
|
||||
+118
-10
@@ -252,6 +252,17 @@ public class PdfJsonConversionService {
|
||||
convertPdfToJson(file, null, false, out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a PDF to the editable {@link PdfJsonDocument} model. Convenience wrapper around
|
||||
* {@link #convertPdfToJson(MultipartFile, OutputStream)} for callers that need to inspect or
|
||||
* mutate the document in memory before round-tripping it back to PDF.
|
||||
*/
|
||||
public PdfJsonDocument convertPdfToJsonDocument(MultipartFile file) throws IOException {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
convertPdfToJson(file, null, false, buffer);
|
||||
return objectMapper.readValue(buffer.toByteArray(), PdfJsonDocument.class);
|
||||
}
|
||||
|
||||
public void convertPdfToJson(MultipartFile file, boolean lightweight, OutputStream out)
|
||||
throws IOException {
|
||||
convertPdfToJson(file, null, lightweight, out);
|
||||
@@ -620,6 +631,13 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
byte[] jsonBytes = file.getBytes();
|
||||
PdfJsonDocument pdfJson = objectMapper.readValue(jsonBytes, PdfJsonDocument.class);
|
||||
convertJsonToPdf(pdfJson, out);
|
||||
}
|
||||
|
||||
public void convertJsonToPdf(PdfJsonDocument pdfJson, OutputStream out) throws IOException {
|
||||
if (pdfJson == null) {
|
||||
throw ExceptionUtils.createNullArgumentException("document");
|
||||
}
|
||||
|
||||
List<PdfJsonFont> fontModels = pdfJson.getFonts();
|
||||
if (fontModels == null) {
|
||||
@@ -2076,14 +2094,29 @@ public class PdfJsonConversionService {
|
||||
byte[] toUnicodeBytes = Base64.getDecoder().decode(toUnicodeBase64);
|
||||
String toUnicodeStr = new String(toUnicodeBytes, StandardCharsets.UTF_8);
|
||||
|
||||
// Parse ToUnicode CMap for bfchar and bfrange
|
||||
// Parse ToUnicode CMap for bfchar and bfrange. Both sides go through
|
||||
// parseToUnicodeCodepoint so an 8-hex-char surrogate-pair value on either side
|
||||
// (e.g. "D837DF0E") is decoded as a single supplementary codepoint instead of
|
||||
// overflowing Integer.parseInt and aborting the whole mapping build.
|
||||
java.util.regex.Pattern bfcharPattern =
|
||||
java.util.regex.Pattern.compile("<([0-9A-Fa-f]+)>\\s*<([0-9A-Fa-f]+)>");
|
||||
java.util.regex.Matcher matcher = bfcharPattern.matcher(toUnicodeStr);
|
||||
while (matcher.find()) {
|
||||
int charCode = Integer.parseInt(matcher.group(1), 16);
|
||||
int unicode = Integer.parseInt(matcher.group(2), 16);
|
||||
charCodeToUnicode.put(charCode, unicode);
|
||||
try {
|
||||
int charCode = parseToUnicodeCodepoint(matcher.group(1));
|
||||
int unicode = parseToUnicodeCodepoint(matcher.group(2));
|
||||
charCodeToUnicode.put(charCode, unicode);
|
||||
} catch (NumberFormatException entryEx) {
|
||||
// Tolerate a single malformed entry: log and skip rather than aborting the
|
||||
// entire ToUnicode CMap (which would force the whole font onto the slow
|
||||
// raw-bytes fallback path).
|
||||
log.debug(
|
||||
"Skipping malformed ToUnicode entry <{}> <{}> in font {}: {}",
|
||||
matcher.group(1),
|
||||
matcher.group(2),
|
||||
font.getName(),
|
||||
entryEx.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Build JSON mapping: CharCode → CID → GID → Unicode
|
||||
@@ -2136,6 +2169,42 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a hex string from a PDF ToUnicode CMap into a single Unicode codepoint. Handles three
|
||||
* cases: a single BMP code unit (4 hex chars), a UTF-16 surrogate pair encoding a supplementary
|
||||
* codepoint above U+FFFF (8 hex chars, e.g. {@code D837DF0E} for U+1F40E), and multi-codepoint
|
||||
* mappings (longer; returns the first codepoint as a best-effort representative).
|
||||
*
|
||||
* <p>Without this, {@code Integer.parseInt("D837DF0E", 16)} overflows because the value is ~3.6
|
||||
* billion, throwing {@link NumberFormatException} and forcing the conversion to fall back to a
|
||||
* raw ToUnicode payload that the JSON→PDF rebuild then fails to use efficiently.
|
||||
*/
|
||||
static int parseToUnicodeCodepoint(String hex) {
|
||||
if (hex == null || hex.isEmpty()) {
|
||||
throw new NumberFormatException("Empty ToUnicode hex value");
|
||||
}
|
||||
if (hex.length() <= 4) {
|
||||
return Integer.parseInt(hex, 16);
|
||||
}
|
||||
// Treat the hex string as UTF-16BE: pairs of hex digits form bytes, four hex digits form
|
||||
// one UTF-16 code unit. The PDF ToUnicode CMap convention requires an even number of bytes
|
||||
// (i.e. a multiple of four hex characters) for multi-unit values.
|
||||
if (hex.length() % 4 != 0) {
|
||||
throw new NumberFormatException(
|
||||
"ToUnicode hex value not a multiple of 4 chars: " + hex);
|
||||
}
|
||||
int unitCount = hex.length() / 4;
|
||||
char[] units = new char[unitCount];
|
||||
for (int i = 0; i < unitCount; i++) {
|
||||
units[i] = (char) Integer.parseInt(hex.substring(i * 4, i * 4 + 4), 16);
|
||||
}
|
||||
// codePointAt assembles a surrogate pair into a supplementary codepoint when the
|
||||
// high/low surrogates appear in sequence; for any other multi-unit sequence it returns
|
||||
// the first BMP codepoint, which is the right best-effort fallback for ligature
|
||||
// decompositions (one charCode -> several Unicode chars).
|
||||
return new String(units).codePointAt(0);
|
||||
}
|
||||
|
||||
private PdfJsonFontCidSystemInfo extractCidSystemInfo(COSDictionary fontDictionary) {
|
||||
if (fontDictionary == null) {
|
||||
return null;
|
||||
@@ -4256,12 +4325,8 @@ public class PdfJsonConversionService {
|
||||
return 0;
|
||||
}
|
||||
if (font != null) {
|
||||
try (InputStream inputStream = new ByteArrayInputStream(value.getBytes())) {
|
||||
int count = 0;
|
||||
int code;
|
||||
while ((code = font.readCode(inputStream)) != -1) {
|
||||
count++;
|
||||
}
|
||||
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(value.getBytes())) {
|
||||
int count = countCodesProtected(inputStream, font::readCode);
|
||||
if (count > 0) {
|
||||
return count;
|
||||
}
|
||||
@@ -4273,6 +4338,49 @@ public class PdfJsonConversionService {
|
||||
return Math.max(1, bytes.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional accessor for {@link PDFont#readCode(InputStream)} so the bounded counting loop can
|
||||
* be exercised in isolation without instantiating a {@link PDFont}.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
interface CodeReader {
|
||||
int readCode(InputStream stream) throws IOException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many codes the supplied {@code reader} can extract from {@code inputStream}, with
|
||||
* two safety nets that PDFBox's raw {@link PDFont#readCode(InputStream)} loop lacks:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Stop when the stream is empty (a corrupt CMap can otherwise loop forever returning
|
||||
* successfully-matched zero-bytes from an exhausted {@link ByteArrayInputStream}).
|
||||
* <li>Stop when a {@code readCode} call did not consume any bytes, even if it returned a
|
||||
* non-{@code -1} value.
|
||||
* </ol>
|
||||
*
|
||||
* <p>Both conditions were observed in the wild on round-tripped fallback fonts where the
|
||||
* embedded ToUnicode CMap matched 0x00 sequences, hanging the JSON→PDF rebuild.
|
||||
*/
|
||||
static int countCodesProtected(ByteArrayInputStream inputStream, CodeReader reader)
|
||||
throws IOException {
|
||||
int count = 0;
|
||||
int previousAvailable = inputStream.available();
|
||||
while (previousAvailable > 0) {
|
||||
int code = reader.readCode(inputStream);
|
||||
if (code == -1) {
|
||||
break;
|
||||
}
|
||||
int currentAvailable = inputStream.available();
|
||||
if (currentAvailable >= previousAvailable) {
|
||||
// No progress made; break to avoid infinite loop on corrupt CMaps.
|
||||
break;
|
||||
}
|
||||
count++;
|
||||
previousAvailable = currentAvailable;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private MergedText mergeText(List<PdfJsonTextElement> elements) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
List<Integer> combinedCodes = new ArrayList<>();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,206 +1,205 @@
|
||||
/* Light theme variables */
|
||||
:root {
|
||||
--cc-bg: #ffffff;
|
||||
--cc-primary-color: #1c1c1c;
|
||||
--cc-secondary-color: #666666;
|
||||
--cc-bg: #ffffff;
|
||||
--cc-primary-color: #1c1c1c;
|
||||
--cc-secondary-color: #666666;
|
||||
|
||||
--cc-btn-primary-bg: #007BFF;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #007BFF;
|
||||
--cc-btn-primary-hover-bg: #0056b3;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #0056b3;
|
||||
--cc-btn-primary-bg: #007bff;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #007bff;
|
||||
--cc-btn-primary-hover-bg: #0056b3;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #0056b3;
|
||||
|
||||
--cc-btn-secondary-bg: #f1f3f4;
|
||||
--cc-btn-secondary-color: #1c1c1c;
|
||||
--cc-btn-secondary-border-color: #f1f3f4;
|
||||
--cc-btn-secondary-hover-bg: #007BFF;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #007BFF;
|
||||
--cc-btn-secondary-bg: #f1f3f4;
|
||||
--cc-btn-secondary-color: #1c1c1c;
|
||||
--cc-btn-secondary-border-color: #f1f3f4;
|
||||
--cc-btn-secondary-hover-bg: #007bff;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #007bff;
|
||||
|
||||
--cc-separator-border-color: #e0e0e0;
|
||||
--cc-separator-border-color: #e0e0e0;
|
||||
|
||||
--cc-toggle-on-bg: #007BFF;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #ffffff;
|
||||
--cc-toggle-off-knob-bg: #ffffff;
|
||||
--cc-toggle-on-bg: #007bff;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #ffffff;
|
||||
--cc-toggle-off-knob-bg: #ffffff;
|
||||
|
||||
--cc-toggle-enabled-icon-color: #ffffff;
|
||||
--cc-toggle-disabled-icon-color: #ffffff;
|
||||
--cc-toggle-enabled-icon-color: #ffffff;
|
||||
--cc-toggle-disabled-icon-color: #ffffff;
|
||||
|
||||
--cc-toggle-readonly-bg: #f1f3f4;
|
||||
--cc-toggle-readonly-knob-bg: #79747E;
|
||||
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
|
||||
--cc-toggle-readonly-bg: #f1f3f4;
|
||||
--cc-toggle-readonly-knob-bg: #79747e;
|
||||
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
|
||||
|
||||
--cc-section-category-border: #e0e0e0;
|
||||
--cc-section-category-border: #e0e0e0;
|
||||
|
||||
--cc-cookie-category-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-block-border: #f1f3f4;
|
||||
--cc-cookie-category-block-hover-bg: #e9eff4;
|
||||
--cc-cookie-category-block-hover-border: #e9eff4;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
|
||||
--cc-cookie-category-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-block-border: #f1f3f4;
|
||||
--cc-cookie-category-block-hover-bg: #e9eff4;
|
||||
--cc-cookie-category-block-hover-border: #e9eff4;
|
||||
|
||||
--cc-footer-bg: #ffffff;
|
||||
--cc-footer-color: #1c1c1c;
|
||||
--cc-footer-border-color: #ffffff;
|
||||
--cc-cookie-category-expanded-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
|
||||
|
||||
--cc-footer-bg: #ffffff;
|
||||
--cc-footer-color: #1c1c1c;
|
||||
--cc-footer-border-color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark theme variables */
|
||||
.cc--darkmode{
|
||||
--cc-bg: #2d2d2d;
|
||||
--cc-primary-color: #e5e5e5;
|
||||
--cc-secondary-color: #b0b0b0;
|
||||
.cc--darkmode {
|
||||
--cc-bg: #2d2d2d;
|
||||
--cc-primary-color: #e5e5e5;
|
||||
--cc-secondary-color: #b0b0b0;
|
||||
|
||||
--cc-btn-primary-bg: #4dabf7;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #4dabf7;
|
||||
--cc-btn-primary-hover-bg: #3d3d3d;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #3d3d3d;
|
||||
--cc-btn-primary-bg: #4dabf7;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #4dabf7;
|
||||
--cc-btn-primary-hover-bg: #3d3d3d;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #3d3d3d;
|
||||
|
||||
--cc-btn-secondary-bg: #3d3d3d;
|
||||
--cc-btn-secondary-color: #ffffff;
|
||||
--cc-btn-secondary-border-color: #3d3d3d;
|
||||
--cc-btn-secondary-hover-bg: #4dabf7;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #4dabf7;
|
||||
--cc-btn-secondary-bg: #3d3d3d;
|
||||
--cc-btn-secondary-color: #ffffff;
|
||||
--cc-btn-secondary-border-color: #3d3d3d;
|
||||
--cc-btn-secondary-hover-bg: #4dabf7;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #4dabf7;
|
||||
|
||||
--cc-separator-border-color: #555555;
|
||||
--cc-separator-border-color: #555555;
|
||||
|
||||
--cc-toggle-on-bg: #4dabf7;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #2d2d2d;
|
||||
--cc-toggle-off-knob-bg: #2d2d2d;
|
||||
--cc-toggle-on-bg: #4dabf7;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #2d2d2d;
|
||||
--cc-toggle-off-knob-bg: #2d2d2d;
|
||||
|
||||
--cc-toggle-enabled-icon-color: #2d2d2d;
|
||||
--cc-toggle-disabled-icon-color: #2d2d2d;
|
||||
--cc-toggle-enabled-icon-color: #2d2d2d;
|
||||
--cc-toggle-disabled-icon-color: #2d2d2d;
|
||||
|
||||
--cc-toggle-readonly-bg: #555555;
|
||||
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
||||
--cc-toggle-readonly-knob-icon-color: #555555;
|
||||
--cc-toggle-readonly-bg: #555555;
|
||||
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
||||
--cc-toggle-readonly-knob-icon-color: #555555;
|
||||
|
||||
--cc-section-category-border: #555555;
|
||||
--cc-section-category-border: #555555;
|
||||
|
||||
--cc-cookie-category-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-block-border: #3d3d3d;
|
||||
--cc-cookie-category-block-hover-bg: #4d4d4d;
|
||||
--cc-cookie-category-block-hover-border: #4d4d4d;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
|
||||
--cc-cookie-category-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-block-border: #3d3d3d;
|
||||
--cc-cookie-category-block-hover-bg: #4d4d4d;
|
||||
--cc-cookie-category-block-hover-border: #4d4d4d;
|
||||
|
||||
--cc-footer-bg: #2d2d2d;
|
||||
--cc-footer-color: #e5e5e5;
|
||||
--cc-footer-border-color: #2d2d2d;
|
||||
--cc-cookie-category-expanded-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
|
||||
|
||||
--cc-footer-bg: #2d2d2d;
|
||||
--cc-footer-color: #e5e5e5;
|
||||
--cc-footer-border-color: #2d2d2d;
|
||||
}
|
||||
.cm__body{
|
||||
max-width: 90% !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
|
||||
.cm__body {
|
||||
max-width: 90% !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.cm__desc{
|
||||
max-width: 70rem !important;
|
||||
.cm__desc {
|
||||
max-width: 70rem !important;
|
||||
}
|
||||
|
||||
.cm__btns{
|
||||
flex-direction: row-reverse !important;
|
||||
gap:10px !important;
|
||||
padding-top: 3.4rem !important;
|
||||
.cm__btns {
|
||||
flex-direction: row-reverse !important;
|
||||
gap: 10px !important;
|
||||
padding-top: 3.4rem !important;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1400px) {
|
||||
.cm__body{
|
||||
max-width: 90% !important;
|
||||
flex-direction: column !important;
|
||||
align-items: normal !important;
|
||||
}
|
||||
.cm__body {
|
||||
max-width: 90% !important;
|
||||
flex-direction: column !important;
|
||||
align-items: normal !important;
|
||||
}
|
||||
|
||||
.cm__btns{
|
||||
padding-top: 1rem !important;
|
||||
}
|
||||
.cm__btns {
|
||||
padding-top: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toggle visibility fixes */
|
||||
#cc-main .section__toggle {
|
||||
opacity: 0 !important; /* Keep invisible but functional */
|
||||
opacity: 0 !important; /* Keep invisible but functional */
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon-circle {
|
||||
display: block !important;
|
||||
position: absolute !important;
|
||||
transition: transform 0.25s ease !important;
|
||||
display: block !important;
|
||||
position: absolute !important;
|
||||
transition: transform 0.25s ease !important;
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon-on,
|
||||
#cc-main .toggle__icon-off {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
position: absolute !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
position: absolute !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Ensure toggles are visible in both themes */
|
||||
#cc-main .toggle__icon {
|
||||
background: var(--cc-toggle-off-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-off-bg) !important;
|
||||
background: var(--cc-toggle-off-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-off-bg) !important;
|
||||
}
|
||||
|
||||
#cc-main .section__toggle:checked ~ .toggle__icon {
|
||||
background: var(--cc-toggle-on-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-on-bg) !important;
|
||||
background: var(--cc-toggle-on-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-on-bg) !important;
|
||||
}
|
||||
|
||||
/* Ensure toggle text is visible */
|
||||
#cc-main .pm__section-title {
|
||||
color: var(--cc-primary-color) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .pm__section-desc {
|
||||
color: var(--cc-secondary-color) !important;
|
||||
color: var(--cc-secondary-color) !important;
|
||||
}
|
||||
|
||||
/* Make sure the modal has proper contrast */
|
||||
#cc-main .pm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* Ensure consent modal text is visible in both themes */
|
||||
#cc-main .cm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__title {
|
||||
color: var(--cc-primary-color) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__desc {
|
||||
color: var(--cc-primary-color) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__footer {
|
||||
color: var(--cc-primary-color) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__footer-links a,
|
||||
#cc-main .cm__link {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
@@ -27,60 +27,19 @@ class EndpointInterceptorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleAllowsEnabledApiEndpoint() throws Exception {
|
||||
void preHandleAllowsEnabledEndpoint() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/general/remove-pages");
|
||||
when(endpointConfiguration.isEndpointEnabled("remove-pages")).thenReturn(true);
|
||||
when(endpointConfiguration.isEndpointEnabledForUri("/api/v1/general/remove-pages"))
|
||||
.thenReturn(true);
|
||||
assertTrue(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleBlocksDisabledApiEndpoint() throws Exception {
|
||||
void preHandleBlocksDisabledEndpoint() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/general/remove-pages");
|
||||
when(endpointConfiguration.isEndpointEnabled("remove-pages")).thenReturn(false);
|
||||
when(endpointConfiguration.isEndpointEnabledForUri("/api/v1/general/remove-pages"))
|
||||
.thenReturn(false);
|
||||
assertFalse(interceptor.preHandle(request, response, new Object()));
|
||||
verify(response).sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleExtractsConvertEndpointCorrectly() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/convert/pdf/img");
|
||||
when(endpointConfiguration.isEndpointEnabled("pdf-to-img")).thenReturn(true);
|
||||
assertTrue(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleBlocksDisabledConvertEndpoint() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/convert/pdf/img");
|
||||
when(endpointConfiguration.isEndpointEnabled("pdf-to-img")).thenReturn(false);
|
||||
assertFalse(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleUsesFullUriForNonApiPaths() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/some-page");
|
||||
when(endpointConfiguration.isEndpointEnabled("/some-page")).thenReturn(true);
|
||||
assertTrue(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleBlocksDisabledNonApiPath() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/some-page");
|
||||
when(endpointConfiguration.isEndpointEnabled("/some-page")).thenReturn(false);
|
||||
assertFalse(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleUsesFullUriForShortApiPath() throws Exception {
|
||||
// URI with /api/v1 but not enough segments (split length <= 4)
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/general");
|
||||
when(endpointConfiguration.isEndpointEnabled("/api/v1/general")).thenReturn(true);
|
||||
assertTrue(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleExtractsNonConvertApiEndpoint() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/security/add-watermark");
|
||||
when(endpointConfiguration.isEndpointEnabled("add-watermark")).thenReturn(true);
|
||||
assertTrue(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
}
|
||||
|
||||
+664
@@ -0,0 +1,664 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.EditTextRequest;
|
||||
import stirling.software.SPDF.model.json.PdfJsonDocument;
|
||||
import stirling.software.SPDF.model.json.PdfJsonPage;
|
||||
import stirling.software.SPDF.model.json.PdfJsonTextElement;
|
||||
import stirling.software.SPDF.service.PdfJsonConversionService;
|
||||
import stirling.software.common.model.api.general.EditTextOperation;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class EditTextControllerTest {
|
||||
|
||||
@Mock private PdfJsonConversionService pdfJsonConversionService;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@InjectMocks private EditTextController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
private static MultipartFile pdfFile() {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "stub-pdf-bytes".getBytes());
|
||||
}
|
||||
|
||||
private static EditTextOperation edit(String find, String replace) {
|
||||
EditTextOperation op = new EditTextOperation();
|
||||
op.setFind(find);
|
||||
op.setReplace(replace);
|
||||
return op;
|
||||
}
|
||||
|
||||
private static PdfJsonTextElement textElement(String text) {
|
||||
PdfJsonTextElement el = new PdfJsonTextElement();
|
||||
el.setText(text);
|
||||
el.setCharCodes(new int[] {1, 2, 3});
|
||||
return el;
|
||||
}
|
||||
|
||||
private static PdfJsonDocument documentWithElements(List<PdfJsonTextElement> elements) {
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
PdfJsonPage page = new PdfJsonPage();
|
||||
page.setPageNumber(1);
|
||||
page.setTextElements(new ArrayList<>(elements));
|
||||
doc.setPages(new ArrayList<>(List.of(page)));
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static PdfJsonDocument documentWithText(String... textsByPage) {
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
List<PdfJsonPage> pages = new ArrayList<>();
|
||||
for (int i = 0; i < textsByPage.length; i++) {
|
||||
PdfJsonPage page = new PdfJsonPage();
|
||||
page.setPageNumber(i + 1);
|
||||
PdfJsonTextElement el = new PdfJsonTextElement();
|
||||
el.setText(textsByPage[i]);
|
||||
el.setCharCodes(new int[] {1, 2, 3});
|
||||
page.setTextElements(new ArrayList<>(List.of(el)));
|
||||
pages.add(page);
|
||||
}
|
||||
doc.setPages(pages);
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Build a minimal real PDF so tests can run end-to-end without mocking the conversion. */
|
||||
private static byte[] buildEmptyPdf() throws Exception {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
doc.addPage(new PDPage());
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_nullFileInputThrows() {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(null);
|
||||
request.setEdits(List.of(edit("foo", "bar")));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.editText(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_emptyEditsThrows() {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of());
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.editText(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_nullEditsThrows() {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(null);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.editText(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_emptyFindStringThrows() {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("", "replacement")));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.editText(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_findStringWithRegexMetacharsIsTreatedLiterally() throws Exception {
|
||||
// Find strings are always treated as literals (Pattern.quote'd internally) — no ReDoS
|
||||
// exposure from regex metacharacters supplied by the caller.
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("(unclosed", "fixed")));
|
||||
|
||||
PdfJsonDocument input = documentWithText("text with (unclosed paren");
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
assertEquals(
|
||||
"text with fixed paren",
|
||||
captor.getValue().getPages().get(0).getTextElements().get(0).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_literalFindReplace_mutatesMatchingSpansAndClearsCharCodes() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("foo", "bar")));
|
||||
|
||||
PdfJsonDocument input = documentWithText("foo and foo", "no match here");
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
ResponseEntity<Resource> response = controller.editText(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
PdfJsonDocument mutated = captor.getValue();
|
||||
|
||||
assertEquals("bar and bar", mutated.getPages().get(0).getTextElements().get(0).getText());
|
||||
assertNull(mutated.getPages().get(0).getTextElements().get(0).getCharCodes());
|
||||
|
||||
assertEquals("no match here", mutated.getPages().get(1).getTextElements().get(0).getText());
|
||||
// Char codes preserved on unmodified spans.
|
||||
assertNotNull(mutated.getPages().get(1).getTextElements().get(0).getCharCodes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_wholeWordSearch_doesNotMatchInsideWords() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("cat", "dog")));
|
||||
request.setWholeWordSearch(true);
|
||||
|
||||
PdfJsonDocument input = documentWithText("the cat is in the catalogue");
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
assertEquals(
|
||||
"the dog is in the catalogue",
|
||||
captor.getValue().getPages().get(0).getTextElements().get(0).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_wholeWordSearch_matchesFindStartingWithNonWordChar() throws Exception {
|
||||
// Regression: \b only fires on a word/non-word transition. A find that starts with a
|
||||
// non-word char (e.g. "-foo") preceded by another non-word char in the source (a space)
|
||||
// would never match under \b. The lookaround-based bound handles this correctly.
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("-foo", "-bar")));
|
||||
request.setWholeWordSearch(true);
|
||||
|
||||
PdfJsonDocument input = documentWithText("space then -foo here");
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
assertEquals(
|
||||
"space then -bar here",
|
||||
captor.getValue().getPages().get(0).getTextElements().get(0).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_wholeWordSearch_doesNotMatchWhenAdjacentToWordChar() throws Exception {
|
||||
// The lookaround bound must still reject matches that are part of a larger word.
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("-foo", "-bar")));
|
||||
request.setWholeWordSearch(true);
|
||||
|
||||
PdfJsonDocument input = documentWithText("inline-foo should not match");
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
// "inline-foo" has 'e' (word) before '-foo' so the lookbehind blocks the match. The
|
||||
// trailing 'o' is followed by a space (non-word) so the trailing lookahead would pass on
|
||||
// its own; the leading lookbehind is what rejects it.
|
||||
assertEquals(
|
||||
"inline-foo should not match",
|
||||
captor.getValue().getPages().get(0).getTextElements().get(0).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_pageFilter_onlyAffectsListedPages() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("foo", "bar")));
|
||||
request.setPageNumbers("2");
|
||||
|
||||
PdfJsonDocument input = documentWithText("foo on page 1", "foo on page 2", "foo on page 3");
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
PdfJsonDocument mutated = captor.getValue();
|
||||
|
||||
assertEquals("foo on page 1", mutated.getPages().get(0).getTextElements().get(0).getText());
|
||||
assertEquals("bar on page 2", mutated.getPages().get(1).getTextElements().get(0).getText());
|
||||
assertEquals("foo on page 3", mutated.getPages().get(2).getTextElements().get(0).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_pageRange_appliesToAllPagesInRange() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("foo", "bar")));
|
||||
request.setPageNumbers("2-3");
|
||||
|
||||
PdfJsonDocument input =
|
||||
documentWithText("foo page 1", "foo page 2", "foo page 3", "foo page 4");
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
PdfJsonDocument mutated = captor.getValue();
|
||||
|
||||
assertTrue(mutated.getPages().get(0).getTextElements().get(0).getText().startsWith("foo"));
|
||||
assertTrue(mutated.getPages().get(1).getTextElements().get(0).getText().startsWith("bar"));
|
||||
assertTrue(mutated.getPages().get(2).getTextElements().get(0).getText().startsWith("bar"));
|
||||
assertTrue(mutated.getPages().get(3).getTextElements().get(0).getText().startsWith("foo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_orderedEdits_applyInSequence() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("foo", "bar"), edit("bar", "baz")));
|
||||
|
||||
PdfJsonDocument input = documentWithText("foo");
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
assertEquals("baz", captor.getValue().getPages().get(0).getTextElements().get(0).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_replaceWithEmptyString_deletesMatch() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("DRAFT ", "")));
|
||||
|
||||
PdfJsonDocument input = documentWithText("DRAFT Confidential Memo");
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
assertEquals(
|
||||
"Confidential Memo",
|
||||
captor.getValue().getPages().get(0).getTextElements().get(0).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_noMatches_returnsPdfWithoutMutation() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("notfound", "replacement")));
|
||||
|
||||
PdfJsonDocument input = documentWithText("nothing to match");
|
||||
int[] originalCodes = input.getPages().get(0).getTextElements().get(0).getCharCodes();
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
ResponseEntity<Resource> response = controller.editText(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
// Char codes left intact when nothing was replaced.
|
||||
assertEquals(
|
||||
originalCodes, input.getPages().get(0).getTextElements().get(0).getCharCodes());
|
||||
assertEquals(
|
||||
"nothing to match", input.getPages().get(0).getTextElements().get(0).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_dollarInLiteralReplacement_isQuoted() throws Exception {
|
||||
// Without quoting, '$1' would be interpreted as a backreference and crash.
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("price", "$100")));
|
||||
|
||||
PdfJsonDocument input = documentWithText("the price is set");
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
assertEquals(
|
||||
"the $100 is set",
|
||||
captor.getValue().getPages().get(0).getTextElements().get(0).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_emptyDocument_returnsResponseWithoutErrors() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("foo", "bar")));
|
||||
|
||||
PdfJsonDocument input = new PdfJsonDocument();
|
||||
input.setPages(new ArrayList<>());
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
ResponseEntity<Resource> response = controller.editText(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_textElementWithNullText_isSkipped() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("foo", "bar")));
|
||||
|
||||
PdfJsonDocument input = new PdfJsonDocument();
|
||||
PdfJsonPage page = new PdfJsonPage();
|
||||
page.setPageNumber(1);
|
||||
PdfJsonTextElement nullText = new PdfJsonTextElement();
|
||||
nullText.setText(null);
|
||||
PdfJsonTextElement realText = new PdfJsonTextElement();
|
||||
realText.setText("foo here");
|
||||
page.setTextElements(new ArrayList<>(List.of(nullText, realText)));
|
||||
input.setPages(new ArrayList<>(List.of(page)));
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
PdfJsonDocument mutated = captor.getValue();
|
||||
assertNull(mutated.getPages().get(0).getTextElements().get(0).getText());
|
||||
assertEquals("bar here", mutated.getPages().get(0).getTextElements().get(1).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_crossElement_matchSpansTwoElements() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("Hello World", "Goodbye Earth")));
|
||||
|
||||
PdfJsonDocument input =
|
||||
documentWithElements(List.of(textElement("Hello "), textElement("World")));
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
List<PdfJsonTextElement> elements = captor.getValue().getPages().get(0).getTextElements();
|
||||
|
||||
// Whole replacement lands in the first matched element; the second is emptied. The
|
||||
// JSON->PDF rebuild concatenates per-token text so the font lays out the replacement as
|
||||
// one run anchored at the first element's X position.
|
||||
assertEquals("Goodbye Earth", elements.get(0).getText());
|
||||
assertEquals("", elements.get(1).getText());
|
||||
assertNull(elements.get(0).getCharCodes());
|
||||
assertNull(elements.get(1).getCharCodes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_crossElement_matchSpansFiveElementsLikeFragmentedTitle() throws Exception {
|
||||
// Reproduces the real-world case: a multi-word title fragmented one word per text span.
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(
|
||||
List.of(edit("The Free Adobe Acrobat Alternative", "The PDF automation pipeline")));
|
||||
|
||||
PdfJsonDocument input =
|
||||
documentWithElements(
|
||||
List.of(
|
||||
textElement("The "),
|
||||
textElement("Free "),
|
||||
textElement("Adobe "),
|
||||
textElement("Acrobat "),
|
||||
textElement("Alternative")));
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
List<PdfJsonTextElement> elements = captor.getValue().getPages().get(0).getTextElements();
|
||||
|
||||
// Whole replacement is written into the first matched element; the remaining four are
|
||||
// emptied. Centered titles will become left-aligned at the original first-word X position.
|
||||
assertEquals("The PDF automation pipeline", elements.get(0).getText());
|
||||
for (int i = 1; i < elements.size(); i++) {
|
||||
assertEquals("", elements.get(i).getText(), "element " + i + " should be empty");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_crossElement_preservesPrefixAndSuffix() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("Hello World", "Goodbye Earth")));
|
||||
|
||||
PdfJsonDocument input =
|
||||
documentWithElements(
|
||||
List.of(textElement("Greeting: Hello "), textElement("World! And more")));
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
List<PdfJsonTextElement> elements = captor.getValue().getPages().get(0).getTextElements();
|
||||
|
||||
// First element keeps its prefix and gets the entire replacement; last element keeps its
|
||||
// suffix only.
|
||||
assertEquals("Greeting: Goodbye Earth", elements.get(0).getText());
|
||||
assertEquals("! And more", elements.get(1).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_matchInOneElementOfMany_onlyTouchesThatElement() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("World", "Earth")));
|
||||
|
||||
PdfJsonDocument input =
|
||||
documentWithElements(
|
||||
List.of(
|
||||
textElement("Hello "),
|
||||
textElement("World!"),
|
||||
textElement(" Goodbye")));
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
List<PdfJsonTextElement> elements = captor.getValue().getPages().get(0).getTextElements();
|
||||
|
||||
assertEquals("Hello ", elements.get(0).getText());
|
||||
assertEquals("Earth!", elements.get(1).getText());
|
||||
assertEquals(" Goodbye", elements.get(2).getText());
|
||||
// Only the modified element's char codes get cleared.
|
||||
assertNotNull(elements.get(0).getCharCodes());
|
||||
assertNull(elements.get(1).getCharCodes());
|
||||
assertNotNull(elements.get(2).getCharCodes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_crossElement_multipleMatchesAppliedRightToLeft() throws Exception {
|
||||
// Two matches in the same page text. Right-to-left application keeps earlier indices
|
||||
// valid as later matches are written.
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("foo bar", "X")));
|
||||
|
||||
PdfJsonDocument input =
|
||||
documentWithElements(
|
||||
List.of(
|
||||
textElement("foo "),
|
||||
textElement("bar baz "),
|
||||
textElement("foo "),
|
||||
textElement("bar")));
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
List<PdfJsonTextElement> elements = captor.getValue().getPages().get(0).getTextElements();
|
||||
|
||||
// Joined was "foo bar baz foo bar"; both "foo bar" runs replaced with "X".
|
||||
StringBuilder joined = new StringBuilder();
|
||||
for (PdfJsonTextElement el : elements) {
|
||||
joined.append(el.getText() == null ? "" : el.getText());
|
||||
}
|
||||
assertEquals("X baz X", joined.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_subWordFragmentation_writesIntoFirstElement() throws Exception {
|
||||
// When the matched text is split into many character-level spans (typical of Type3 glyph
|
||||
// runs), the whole replacement is dumped into the first matched element and the rest are
|
||||
// emptied. The font lays out the replacement as one run at the first glyph's X position.
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setEdits(List.of(edit("Hello World", "Goodbye Earth")));
|
||||
|
||||
// 11 sub-word elements covering "Hello World".
|
||||
PdfJsonDocument input =
|
||||
documentWithElements(
|
||||
List.of(
|
||||
textElement("H"),
|
||||
textElement("e"),
|
||||
textElement("l"),
|
||||
textElement("l"),
|
||||
textElement("o"),
|
||||
textElement(" "),
|
||||
textElement("W"),
|
||||
textElement("o"),
|
||||
textElement("r"),
|
||||
textElement("l"),
|
||||
textElement("d")));
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
controller.editText(request);
|
||||
|
||||
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
|
||||
org.mockito.Mockito.verify(pdfJsonConversionService)
|
||||
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
|
||||
List<PdfJsonTextElement> elements = captor.getValue().getPages().get(0).getTextElements();
|
||||
|
||||
// Entire replacement goes into the first matched element; the other 10 are emptied.
|
||||
assertEquals("Goodbye Earth", elements.get(0).getText());
|
||||
for (int i = 1; i < elements.size(); i++) {
|
||||
assertEquals("", elements.get(i).getText(), "element " + i + " should be empty");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void editText_outputFilenameDerivedFromInput() throws Exception {
|
||||
EditTextRequest request = new EditTextRequest();
|
||||
request.setFileInput(
|
||||
new MockMultipartFile(
|
||||
"fileInput", "report.pdf", "application/pdf", buildEmptyPdf()));
|
||||
request.setEdits(List.of(edit("anything", "x")));
|
||||
|
||||
PdfJsonDocument input = documentWithText("nothing matches");
|
||||
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
|
||||
.thenReturn(input);
|
||||
|
||||
ResponseEntity<Resource> response = controller.editText(request);
|
||||
String contentDisposition =
|
||||
response.getHeaders()
|
||||
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
|
||||
assertNotNull(contentDisposition);
|
||||
assertTrue(contentDisposition.contains("report_edited.pdf"));
|
||||
assertFalse(contentDisposition.contains(".pdf_edited.pdf"));
|
||||
}
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AddCommentsRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfAnnotationService;
|
||||
import stirling.software.common.util.PdfTextLocator;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AddCommentsControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private PdfAnnotationService pdfAnnotationService;
|
||||
private PdfTextLocator pdfTextLocator;
|
||||
private ObjectMapper objectMapper;
|
||||
private AddCommentsController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
pdfAnnotationService = new PdfAnnotationService();
|
||||
pdfTextLocator = new PdfTextLocator();
|
||||
objectMapper = JsonMapper.builder().build();
|
||||
controller =
|
||||
new AddCommentsController(
|
||||
pdfDocumentFactory,
|
||||
tempFileManager,
|
||||
pdfAnnotationService,
|
||||
pdfTextLocator,
|
||||
objectMapper);
|
||||
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File file =
|
||||
Files.createTempFile(tempDir, "addcomments", ".pdf").toFile();
|
||||
TempFile tf = org.mockito.Mockito.mock(TempFile.class);
|
||||
lenient().when(tf.getPath()).thenReturn(file.toPath());
|
||||
lenient().when(tf.getFile()).thenReturn(file);
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesEachCommentSpecAsStickyNote() throws Exception {
|
||||
MockMultipartFile file = pdf("doc.pdf", twoPagePdfBytes());
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
|
||||
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(file);
|
||||
request.setComments(
|
||||
"""
|
||||
[{"pageIndex":0,"x":72,"y":700,"width":20,"height":20,"text":"First","author":"me","subject":"S1"},
|
||||
{"pageIndex":1,"x":100,"y":650,"width":20,"height":20,"text":"Second"}]
|
||||
""");
|
||||
|
||||
ResponseEntity<Resource> response = controller.addComments(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
byte[] result = drainBody(response);
|
||||
try (PDDocument reloaded = Loader.loadPDF(result)) {
|
||||
List<PDAnnotationText> p0 = textAnnotations(reloaded.getPage(0).getAnnotations());
|
||||
List<PDAnnotationText> p1 = textAnnotations(reloaded.getPage(1).getAnnotations());
|
||||
assertThat(p0).hasSize(1);
|
||||
assertThat(p1).hasSize(1);
|
||||
assertThat(p0.get(0).getContents()).isEqualTo("First");
|
||||
assertThat(p1.get(0).getContents()).isEqualTo("Second");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anchorsStickyNoteAtLocatedTextWhenAnchorTextMatches() throws Exception {
|
||||
byte[] pdfBytes = singlePagePdfWithLine("Revenue: $215,000");
|
||||
MockMultipartFile file = pdf("doc.pdf", pdfBytes);
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
|
||||
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(file);
|
||||
// Fallback coords deliberately far from the line so we can tell which path ran.
|
||||
request.setComments(
|
||||
"""
|
||||
[{"pageIndex":0,"x":10,"y":10,"width":5,"height":5,
|
||||
"text":"Check this total","author":"tester","subject":"S",
|
||||
"anchorText":"215000"}]
|
||||
""");
|
||||
|
||||
ResponseEntity<Resource> response = controller.addComments(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
|
||||
List<PDAnnotationText> notes = textAnnotations(reloaded.getPage(0).getAnnotations());
|
||||
assertThat(notes).hasSize(1);
|
||||
PDRectangle rect = notes.get(0).getRectangle();
|
||||
// Line was drawn at user-space y=720 with font size 12; icon should land in that band,
|
||||
// not at the fallback y=10. Width/height fixed to 20 by the anchor path.
|
||||
assertThat(rect.getWidth()).isEqualTo(20f);
|
||||
assertThat(rect.getHeight()).isEqualTo(20f);
|
||||
assertThat(rect.getLowerLeftY()).isBetween(700f, 740f);
|
||||
assertThat(rect.getLowerLeftX()).isGreaterThan(50f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToAbsoluteCoordsWhenAnchorTextMisses() throws Exception {
|
||||
byte[] pdfBytes = singlePagePdfWithLine("Revenue: $215,000");
|
||||
MockMultipartFile file = pdf("doc.pdf", pdfBytes);
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
|
||||
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(file);
|
||||
request.setComments(
|
||||
"""
|
||||
[{"pageIndex":0,"x":55,"y":33,"width":7,"height":9,
|
||||
"text":"No match","anchorText":"not-on-this-page"}]
|
||||
""");
|
||||
|
||||
ResponseEntity<Resource> response = controller.addComments(request);
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
|
||||
List<PDAnnotationText> notes = textAnnotations(reloaded.getPage(0).getAnnotations());
|
||||
assertThat(notes).hasSize(1);
|
||||
PDRectangle rect = notes.get(0).getRectangle();
|
||||
assertThat(rect.getLowerLeftX()).isEqualTo(55f);
|
||||
assertThat(rect.getLowerLeftY()).isEqualTo(33f);
|
||||
assertThat(rect.getWidth()).isEqualTo(7f);
|
||||
assertThat(rect.getHeight()).isEqualTo(9f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankCommentsJson() {
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(pdf("doc.pdf", new byte[] {1, 2, 3}));
|
||||
request.setComments("");
|
||||
|
||||
assertThatThrownBy(() -> controller.addComments(request))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidJson() {
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(pdf("doc.pdf", new byte[] {1, 2, 3}));
|
||||
request.setComments("not-json");
|
||||
|
||||
assertThatThrownBy(() -> controller.addComments(request))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingFileInput() {
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setComments("[]");
|
||||
|
||||
assertThatThrownBy(() -> controller.addComments(request))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsSuccessForEmptyCommentsArray() throws Exception {
|
||||
// An empty JSON array is a valid payload — nothing to annotate, but the caller
|
||||
// should still get back the input PDF without any error so pipelines that
|
||||
// produce zero comments don't have to special-case the empty result.
|
||||
MockMultipartFile file = pdf("doc.pdf", twoPagePdfBytes());
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
|
||||
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(file);
|
||||
request.setComments("[]");
|
||||
|
||||
ResponseEntity<Resource> response = controller.addComments(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(textAnnotations(reloaded.getPage(0).getAnnotations())).isEmpty();
|
||||
assertThat(textAnnotations(reloaded.getPage(1).getAnnotations())).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static MockMultipartFile pdf(String name, byte[] bytes) {
|
||||
return new MockMultipartFile("fileInput", name, MediaType.APPLICATION_PDF_VALUE, bytes);
|
||||
}
|
||||
|
||||
private static byte[] twoPagePdfBytes() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] singlePagePdfWithLine(String line) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.setNonStrokingColor(Color.BLACK);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(72f, 720f);
|
||||
cs.showText(line);
|
||||
cs.endText();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (java.io.InputStream is = response.getBody().getInputStream()) {
|
||||
is.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static List<PDAnnotationText> textAnnotations(List<PDAnnotation> annotations) {
|
||||
List<PDAnnotationText> out = new ArrayList<>();
|
||||
for (PDAnnotation a : annotations) {
|
||||
if (a instanceof PDAnnotationText t) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -177,14 +177,14 @@ class GlobalExceptionHandlerTest {
|
||||
void handleMaxUploadSize_returns_413() {
|
||||
MaxUploadSizeExceededException ex = new MaxUploadSizeExceededException(10485760);
|
||||
ResponseEntity<ProblemDetail> resp = handler.handleMaxUploadSize(ex, request);
|
||||
assertEquals(HttpStatus.PAYLOAD_TOO_LARGE, resp.getStatusCode());
|
||||
assertEquals(HttpStatus.CONTENT_TOO_LARGE, resp.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleMaxUploadSize_unknown_limit() {
|
||||
MaxUploadSizeExceededException ex = new MaxUploadSizeExceededException(-1);
|
||||
ResponseEntity<ProblemDetail> resp = handler.handleMaxUploadSize(ex, request);
|
||||
assertEquals(HttpStatus.PAYLOAD_TOO_LARGE, resp.getStatusCode());
|
||||
assertEquals(HttpStatus.CONTENT_TOO_LARGE, resp.getStatusCode());
|
||||
assertNull(resp.getBody().getProperties().get("maxSizeBytes"));
|
||||
}
|
||||
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PdfJsonConversionService#parseToUnicodeCodepoint(String)}.
|
||||
*
|
||||
* <p>The function exists because PDF ToUnicode CMap entries can encode supplementary-plane
|
||||
* codepoints (above U+FFFF) as UTF-16 surrogate pairs, e.g. {@code <D837DF0E>} for U+1F40E. The
|
||||
* naive implementation that called {@link Integer#parseInt(String, int)} on the whole hex string
|
||||
* threw {@link NumberFormatException} for any 8-char value because they overflow {@code int}. That
|
||||
* triggered a fallback that left fonts with no proper CID mapping and made downstream JSON→PDF
|
||||
* rebuilds slow/hung when rendering text with those fonts.
|
||||
*/
|
||||
class PdfJsonConversionServiceUnicodeParsingTest {
|
||||
|
||||
@Test
|
||||
void parsesSingleBmpCodeUnit() {
|
||||
// Latin capital A.
|
||||
assertEquals(0x41, PdfJsonConversionService.parseToUnicodeCodepoint("0041"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesShortBmpHexValue() {
|
||||
// Some ToUnicode entries omit leading zeros for low codepoints.
|
||||
assertEquals(0x41, PdfJsonConversionService.parseToUnicodeCodepoint("41"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesSupplementaryCodepointFromSurrogatePair() {
|
||||
// U+1F40E HORSE encoded as UTF-16 surrogate pair D83D DC0E. The bug we are fixing was
|
||||
// that Integer.parseInt("D83DDC0E", 16) overflows because D83DDC0E > Integer.MAX_VALUE.
|
||||
assertEquals(0x1F40E, PdfJsonConversionService.parseToUnicodeCodepoint("D83DDC0E"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesSurrogatePairFromUserReportedHang() {
|
||||
// The exact hex value from the user's hang reproducer log:
|
||||
// "Failed to build Unicode mapping for font NotoSans-Regular: For input string:
|
||||
// \"D837DF0E\" under radix 16"
|
||||
// D837 DF0E decodes to U+1DF0E (CJK supplementary). Important assertion: it returns a
|
||||
// valid codepoint instead of overflowing Integer.parseInt and throwing.
|
||||
int expected = new String(new char[] {(char) 0xD837, (char) 0xDF0E}).codePointAt(0);
|
||||
assertEquals(0x1DF0E, expected); // sanity check on the test setup itself
|
||||
assertEquals(expected, PdfJsonConversionService.parseToUnicodeCodepoint("D837DF0E"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesLigatureDecompositionAsFirstCodepoint() {
|
||||
// A ToUnicode entry can map one charCode to multiple Unicode chars (a ligature). PDF
|
||||
// spec allows e.g. <0041 0042> for "AB". Our best-effort behavior is to return the
|
||||
// first codepoint so the mapping is at least roughly meaningful for search/copy.
|
||||
assertEquals(0x41, PdfJsonConversionService.parseToUnicodeCodepoint("00410042"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmptyHex() {
|
||||
assertThrows(
|
||||
NumberFormatException.class,
|
||||
() -> PdfJsonConversionService.parseToUnicodeCodepoint(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullHex() {
|
||||
assertThrows(
|
||||
NumberFormatException.class,
|
||||
() -> PdfJsonConversionService.parseToUnicodeCodepoint(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOddByteCountAboveBmp() {
|
||||
// 6 hex chars is 3 bytes — not a valid UTF-16BE sequence.
|
||||
assertThrows(
|
||||
NumberFormatException.class,
|
||||
() -> PdfJsonConversionService.parseToUnicodeCodepoint("D83DDC"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void countCodesProtectedTerminatesWhenReaderMakesNoProgress() {
|
||||
// Reproduces the user's hang: PDFBox's CMap.readCode can return a successful code (0)
|
||||
// from a stream where no bytes were consumed (corrupt codespace matching 0x00 bytes from
|
||||
// the buffer's uninitialized region after EOF). Without the no-progress guard, the
|
||||
// counting loop in countGlyphs ran forever.
|
||||
ByteArrayInputStream stream = new ByteArrayInputStream(new byte[] {1, 2, 3, 4});
|
||||
PdfJsonConversionService.CodeReader reader = in -> 0; // never reads, always "succeeds"
|
||||
|
||||
int count =
|
||||
assertTimeoutPreemptively(
|
||||
Duration.ofSeconds(2),
|
||||
() -> PdfJsonConversionService.countCodesProtected(stream, reader));
|
||||
|
||||
// First iteration sees no progress and breaks immediately.
|
||||
assertEquals(0, count);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countCodesProtectedTerminatesOnEmptyStream() {
|
||||
ByteArrayInputStream stream = new ByteArrayInputStream(new byte[0]);
|
||||
PdfJsonConversionService.CodeReader reader =
|
||||
in -> {
|
||||
throw new AssertionError("reader must not be called when stream is empty");
|
||||
};
|
||||
|
||||
int count =
|
||||
assertTimeoutPreemptively(
|
||||
Duration.ofSeconds(2),
|
||||
() -> PdfJsonConversionService.countCodesProtected(stream, reader));
|
||||
|
||||
assertEquals(0, count);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countCodesProtectedHonorsExplicitMinusOneReturn() throws IOException {
|
||||
ByteArrayInputStream stream = new ByteArrayInputStream(new byte[] {1, 2, 3});
|
||||
PdfJsonConversionService.CodeReader reader =
|
||||
in -> {
|
||||
int b = in.read();
|
||||
return b == -1 ? -1 : b;
|
||||
};
|
||||
|
||||
int count = PdfJsonConversionService.countCodesProtected(stream, reader);
|
||||
|
||||
assertEquals(3, count);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countCodesProtectedTerminatesIfReaderReadsThenStops() throws IOException {
|
||||
// A reader that consumes one byte then hits a corrupt-CMap pattern returning 0 without
|
||||
// consuming further must still terminate after counting the consumed bytes.
|
||||
ByteArrayInputStream stream = new ByteArrayInputStream(new byte[] {1, 2, 3, 4});
|
||||
PdfJsonConversionService.CodeReader reader =
|
||||
new PdfJsonConversionService.CodeReader() {
|
||||
boolean firstCall = true;
|
||||
|
||||
@Override
|
||||
public int readCode(InputStream in) throws IOException {
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
return in.read();
|
||||
}
|
||||
return 0; // simulates corrupt CMap thereafter
|
||||
}
|
||||
};
|
||||
|
||||
int count =
|
||||
assertTimeoutPreemptively(
|
||||
Duration.ofSeconds(2),
|
||||
() -> PdfJsonConversionService.countCodesProtected(stream, reader));
|
||||
|
||||
assertEquals(1, count);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ spotless {
|
||||
}
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
api 'com.google.guava:guava:33.5.0-jre'
|
||||
api 'com.google.guava:guava:33.6.0-jre'
|
||||
|
||||
api 'org.springframework:spring-jdbc'
|
||||
api 'org.springframework:spring-webmvc'
|
||||
@@ -54,7 +54,7 @@ dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-cache'
|
||||
api 'com.github.ben-manes.caffeine:caffeine'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46'
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.17.0'
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.18.0'
|
||||
|
||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
|
||||
+68
-9
@@ -30,15 +30,19 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.AiEngineEndpointResolver;
|
||||
import stirling.software.proprietary.service.AiWorkflowService;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@@ -53,6 +57,7 @@ public class AiEngineController {
|
||||
private final Executor aiStreamExecutor;
|
||||
private final TaskManager taskManager;
|
||||
private final JobOwnershipService jobOwnershipService;
|
||||
private final AiEngineEndpointResolver endpointResolver;
|
||||
|
||||
/**
|
||||
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
|
||||
@@ -68,13 +73,15 @@ public class AiEngineController {
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor,
|
||||
TaskManager taskManager,
|
||||
JobOwnershipService jobOwnershipService) {
|
||||
JobOwnershipService jobOwnershipService,
|
||||
AiEngineEndpointResolver endpointResolver) {
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.aiWorkflowService = aiWorkflowService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.aiStreamExecutor = aiStreamExecutor;
|
||||
this.taskManager = taskManager;
|
||||
this.jobOwnershipService = jobOwnershipService;
|
||||
this.endpointResolver = endpointResolver;
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
@@ -134,13 +141,32 @@ public class AiEngineController {
|
||||
}
|
||||
|
||||
private void runOrchestrationStream(AiWorkflowRequest request, SseEmitter emitter) {
|
||||
AiWorkflowService.ProgressListener listener =
|
||||
new AiWorkflowService.ProgressListener() {
|
||||
@Override
|
||||
public void onProgress(AiWorkflowProgressEvent event) {
|
||||
sendEvent(emitter, "progress", event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHeartbeat() {
|
||||
// Forward upstream heartbeats so the SSE pipe stays visibly alive between
|
||||
// real progress events; if the frontend has gone away, sendEvent throws,
|
||||
// which propagates up through the stream consumer and closes our upstream
|
||||
// engine connection so the engine can cancel its in-flight workflow.
|
||||
sendEvent(emitter, "heartbeat", Map.of());
|
||||
}
|
||||
};
|
||||
try {
|
||||
AiWorkflowResponse result =
|
||||
aiWorkflowService.orchestrate(
|
||||
request, progress -> sendEvent(emitter, "progress", progress));
|
||||
AiWorkflowResponse result = aiWorkflowService.orchestrate(request, listener);
|
||||
registerFileResultAsJob(result);
|
||||
sendEvent(emitter, "result", result);
|
||||
emitter.complete();
|
||||
} catch (ClientDisconnectedException e) {
|
||||
// The frontend gave up mid-stream. The exception unwinding through orchestrate()
|
||||
// already closed the upstream engine connection (engine sees disconnect and cancels).
|
||||
// The emitter is already toast; nothing useful left to send.
|
||||
log.debug("Client disconnected mid-stream; aborting workflow", e);
|
||||
} catch (Exception e) {
|
||||
log.error("AI orchestration stream failed", e);
|
||||
// Emit an error frame for the frontend and then complete normally. Using
|
||||
@@ -186,7 +212,21 @@ public class AiEngineController {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
|
||||
} catch (IOException e) {
|
||||
log.debug("Failed to send SSE event (client may have disconnected)", e);
|
||||
// Surface the disconnect so the streaming pipeline unwinds: callers higher up close
|
||||
// the upstream engine connection, which lets the engine cancel its in-flight workflow.
|
||||
// Without this, the engine would keep producing (and billing for) tokens whose results
|
||||
// nobody is reading.
|
||||
throw new ClientDisconnectedException("Client disconnected from SSE stream", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by {@link #sendEvent} when the SSE emitter's underlying connection is gone. Treated as
|
||||
* a signal to abort the workflow, not as an error to report.
|
||||
*/
|
||||
private static final class ClientDisconnectedException extends RuntimeException {
|
||||
ClientDisconnectedException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,17 +237,36 @@ public class AiEngineController {
|
||||
"Sends a user message to the PDF edit agent which returns a structured plan"
|
||||
+ " of tool operations to perform")
|
||||
public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IOException {
|
||||
validateJson(requestBody);
|
||||
String response = aiEngineClient.post("/api/v1/pdf/edit", requestBody);
|
||||
JsonNode parsed = parseJson(requestBody);
|
||||
if (!parsed.isObject()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Request body must be a JSON object");
|
||||
}
|
||||
String forwardedBody = withEnabledEndpoints((ObjectNode) parsed);
|
||||
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody);
|
||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
|
||||
}
|
||||
|
||||
private void validateJson(String body) {
|
||||
private JsonNode parseJson(String body) {
|
||||
try {
|
||||
objectMapper.readValue(body, JsonNode.class);
|
||||
return objectMapper.readValue(body, JsonNode.class);
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Request body is not valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Always overwrite {@code enabled_endpoints} with the server's view of which endpoints are
|
||||
* usable. The engine must not trust a client-supplied list - the gate is owned by the Java
|
||||
* EndpointConfiguration. Values are full URL paths (e.g. {@code /api/v1/misc/compress-pdf})
|
||||
* that the engine matches against its {@code ToolEndpoint} enum, silently dropping any it
|
||||
* doesn't recognise (which lets the two sides drift in either direction without breaking).
|
||||
*/
|
||||
private String withEnabledEndpoints(ObjectNode body) {
|
||||
ArrayNode enabled = objectMapper.createArrayNode();
|
||||
endpointResolver.getEnabledEndpointUrls().forEach(enabled::add);
|
||||
body.set("enabled_endpoints", enabled);
|
||||
return body.toString();
|
||||
}
|
||||
}
|
||||
|
||||
+17
-14
@@ -20,21 +20,30 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.Verdict;
|
||||
import stirling.software.proprietary.service.AiToolInputValidator;
|
||||
import stirling.software.proprietary.service.MathAuditorOrchestrator;
|
||||
|
||||
/**
|
||||
* Public entry point for the Math Auditor Agent (mathAuditorAgent).
|
||||
*
|
||||
* <p>Accepts a PDF from the client, hands it to the {@link MathAuditorOrchestrator} which runs the
|
||||
* multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict}.
|
||||
* multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict} as JSON.
|
||||
*
|
||||
* <p>This endpoint is a pure specialist — it produces the structured finding and nothing more.
|
||||
* Presentation (rendering as a chat answer, projecting to PDF comments, etc.) is the responsibility
|
||||
* of the caller (e.g. the orchestrator's {@code delegate_pdf_question} or {@code
|
||||
* delegate_pdf_review} meta-agents).
|
||||
*
|
||||
* <p>Lives under {@code /api/v1/ai/tools/} so it is dispatchable by the AI orchestrator via the
|
||||
* standard {@code InternalApiClient} allowlist — no special-case plumbing needed.
|
||||
*
|
||||
* <p>The raw PDF never leaves Java. Python receives only structured text and CSV data.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai")
|
||||
@RequestMapping("/api/v1/ai/tools")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "AI Engine", description = "AI-powered document analysis endpoints.")
|
||||
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||
public class MathAuditorAgentController {
|
||||
|
||||
private final MathAuditorOrchestrator orchestrator;
|
||||
@@ -49,11 +58,12 @@ public class MathAuditorAgentController {
|
||||
The auditor checks:
|
||||
- Table row and column totals (tally errors)
|
||||
- Inline arithmetic expressions (e.g. "100 + 200 = 300")
|
||||
- Cross-page figure consistency (same figure cited differently on different pages)
|
||||
- Cross-page figure consistency
|
||||
- Prose claims about percentages, growth rates, and comparisons
|
||||
|
||||
The PDF is processed entirely on the Java side; only extracted text and table data
|
||||
are sent to the AI engine.
|
||||
Returns a JSON Verdict describing every discrepancy found. How the Verdict is
|
||||
presented to the end user (chat answer, PDF annotations, etc.) is up to the
|
||||
caller.
|
||||
|
||||
Input: PDF Output: JSON Type: SISO
|
||||
""")
|
||||
@@ -68,11 +78,7 @@ public class MathAuditorAgentController {
|
||||
@RequestParam(value = "tolerance", defaultValue = "0.01")
|
||||
BigDecimal tolerance) {
|
||||
|
||||
String contentType = fileInput.getContentType();
|
||||
if (contentType == null || !contentType.equals("application/pdf")) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
AiToolInputValidator.validatePdfUpload(fileInput);
|
||||
if (tolerance.compareTo(BigDecimal.ZERO) < 0) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
@@ -89,9 +95,6 @@ public class MathAuditorAgentController {
|
||||
} catch (IOException e) {
|
||||
log.error("[math-auditor-agent] IO error during audit", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
} catch (Exception e) {
|
||||
log.error("[math-auditor-agent] unexpected error during audit", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.service.AiToolResponseHeaders;
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Public entry point for the PDF Comment Agent (pdfCommentAgent).
|
||||
*
|
||||
* <p>Accepts a PDF and a natural-language prompt, delegates to {@link PdfCommentAgentOrchestrator}
|
||||
* which consults the Python engine and applies {@code PDAnnotationText} sticky-note annotations,
|
||||
* then streams the annotated PDF back in the response body. This shape matches the rest of the
|
||||
* Stirling tool endpoints ({@code /api/v1/misc/*}, {@code /api/v1/general/*}) and is what the AI
|
||||
* workflow orchestrator expects when dispatching this tool as a plan step.
|
||||
*
|
||||
* <p>The raw PDF never leaves Java. Python only receives positioned text chunks.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/tools")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||
public class PdfCommentAgentController {
|
||||
|
||||
private final PdfCommentAgentOrchestrator orchestrator;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostMapping(
|
||||
value = "/pdf-comment-agent",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
produces = MediaType.APPLICATION_PDF_VALUE)
|
||||
@Operation(
|
||||
summary = "Annotate a PDF with AI-generated sticky-note comments",
|
||||
description =
|
||||
"""
|
||||
Runs the PDF Comment Agent against the supplied PDF. Java extracts positioned
|
||||
text chunks from the document, ships them (with the user's prompt) to the
|
||||
AI engine, then applies the returned comments as standard PDF Text
|
||||
annotations (sticky notes) anchored to the relevant chunks.
|
||||
|
||||
The annotated PDF is streamed back in the response body with
|
||||
Content-Type: application/pdf.
|
||||
|
||||
Input: PDF + prompt Output: PDF Type: SISO
|
||||
""")
|
||||
public ResponseEntity<Resource> pdfCommentAgent(
|
||||
@Parameter(description = "The PDF document to annotate", required = true)
|
||||
@RequestParam("fileInput")
|
||||
MultipartFile fileInput,
|
||||
@Parameter(
|
||||
description =
|
||||
"Natural-language instructions for the AI — what to comment on",
|
||||
required = true)
|
||||
@RequestParam("prompt")
|
||||
String prompt)
|
||||
throws IOException {
|
||||
|
||||
String safeName =
|
||||
fileInput.getOriginalFilename() != null
|
||||
? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_")
|
||||
: "<unnamed>";
|
||||
log.info(
|
||||
"[pdf-comment-agent] request file={} promptLen={}",
|
||||
safeName,
|
||||
prompt == null ? 0 : prompt.length());
|
||||
|
||||
// ResponseStatusException (validation errors) propagates to Spring's default handler;
|
||||
// IOException is re-thrown to produce a 500. Other RuntimeExceptions likewise propagate.
|
||||
AnnotatedPdf annotated = orchestrator.applyComments(fileInput, prompt);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
headers.setContentDispositionFormData("attachment", annotated.fileName());
|
||||
headers.setContentLength(annotated.bytes().length);
|
||||
headers.set(AiToolResponseHeaders.TOOL_REPORT, buildReportHeader(annotated));
|
||||
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(annotated.bytes()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the metadata JSON surfaced in {@link AiToolResponseHeaders#TOOL_REPORT} alongside the
|
||||
* annotated PDF. Kept small (fits comfortably in a header): counts and the agent's rationale so
|
||||
* a chat UI can show "Added 3 comments: <rationale>" alongside the downloaded file.
|
||||
*/
|
||||
private String buildReportHeader(AnnotatedPdf annotated) {
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
node.put("annotationsApplied", annotated.annotationsApplied());
|
||||
node.put("instructionsReceived", annotated.instructionsReceived());
|
||||
if (annotated.rationale() != null) {
|
||||
node.put("rationale", annotated.rationale());
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(node);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to serialise pdf-comment-agent report header: {}", e.getMessage());
|
||||
return "{\"annotationsApplied\":" + annotated.annotationsApplied() + "}";
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Body for {@code POST /api/v1/documents} on the AI engine. Sent by Java when the engine reports
|
||||
* {@code need_ingest} and the requested document's extracted content must be stored before the
|
||||
* workflow can continue.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AiDocumentIngestRequest {
|
||||
|
||||
private String documentId;
|
||||
|
||||
private String source;
|
||||
|
||||
private List<AiPageText> pageText;
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
|
||||
/**
|
||||
* Typed engine-emitted progress detail, mirroring the Python {@code ProgressEvent} discriminated
|
||||
* union (see {@code engine/src/stirling/contracts/progress.py}). Carried inside {@link
|
||||
* AiWorkflowProgressEvent#getEngineDetail()} for {@link AiWorkflowPhase#ENGINE_PROGRESS} events.
|
||||
*
|
||||
* <p>Sealed so adding a new engine-side phase forces a matching subtype on the Java side instead of
|
||||
* silently passing through as an opaque map. The {@code phase} string is the discriminator and
|
||||
* stays on the wire so the frontend (which doesn't know about Java's class hierarchy) can switch on
|
||||
* it.
|
||||
*/
|
||||
@JsonTypeInfo(
|
||||
use = JsonTypeInfo.Id.NAME,
|
||||
include = JsonTypeInfo.As.EXISTING_PROPERTY,
|
||||
property = "phase",
|
||||
visible = true)
|
||||
@JsonSubTypes({
|
||||
@JsonSubTypes.Type(
|
||||
value = AiEngineProgressDetail.WholeDocReadStarted.class,
|
||||
name = "whole_doc_read_started"),
|
||||
@JsonSubTypes.Type(
|
||||
value = AiEngineProgressDetail.WholeDocSliceDone.class,
|
||||
name = "whole_doc_slice_done"),
|
||||
@JsonSubTypes.Type(
|
||||
value = AiEngineProgressDetail.WholeDocCompressionRound.class,
|
||||
name = "whole_doc_compression_round"),
|
||||
@JsonSubTypes.Type(
|
||||
value = AiEngineProgressDetail.WholeDocReadDone.class,
|
||||
name = "whole_doc_read_done"),
|
||||
})
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public sealed interface AiEngineProgressDetail {
|
||||
|
||||
String phase();
|
||||
|
||||
record WholeDocReadStarted(String phase, String question, int pages, int slices)
|
||||
implements AiEngineProgressDetail {}
|
||||
|
||||
record WholeDocSliceDone(
|
||||
String phase,
|
||||
int completed,
|
||||
int total,
|
||||
String pages,
|
||||
int durationMs,
|
||||
int excerpts,
|
||||
int facts)
|
||||
implements AiEngineProgressDetail {}
|
||||
|
||||
record WholeDocCompressionRound(String phase, int roundNumber, int notesIn, int groups)
|
||||
implements AiEngineProgressDetail {}
|
||||
|
||||
record WholeDocReadDone(String phase, int completed, int slices, double durationSeconds)
|
||||
implements AiEngineProgressDetail {}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* A file supplied to the AI engine, identified by a stable opaque id plus a display name.
|
||||
*
|
||||
* <p>Values MUST match {@code AiFile} in {@code engine/src/stirling/contracts/common.py}.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "File reference sent to the AI engine")
|
||||
public class AiFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Opaque, stable identifier. Owned by Java; used as the RAG collection key.")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "Original filename, used by agents in user-facing prompts and responses.")
|
||||
private String name;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** A single page of extracted text for document ingest requests. */
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AiPageText {
|
||||
|
||||
private int pageNumber;
|
||||
|
||||
private String text;
|
||||
}
|
||||
+2
-2
@@ -11,8 +11,8 @@ import lombok.Data;
|
||||
@Schema(description = "Per-file content extraction request from the AI engine")
|
||||
public class AiWorkflowFileRequest {
|
||||
|
||||
@Schema(description = "Original filename of the requested file", example = "contract.pdf")
|
||||
private String fileName;
|
||||
@Schema(description = "The file the engine wants content extracted for")
|
||||
private AiFile file;
|
||||
|
||||
@Schema(description = "Specific 1-based page numbers to extract from this file")
|
||||
private List<Integer> pageNumbers = new ArrayList<>();
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ public enum AiWorkflowOutcome {
|
||||
ANSWER("answer"),
|
||||
NOT_FOUND("not_found"),
|
||||
NEED_CONTENT("need_content"),
|
||||
NEED_INGEST("need_ingest"),
|
||||
PLAN("plan"),
|
||||
NEED_CLARIFICATION("need_clarification"),
|
||||
CANNOT_DO("cannot_do"),
|
||||
|
||||
+7
-1
@@ -9,7 +9,13 @@ public enum AiWorkflowPhase {
|
||||
CALLING_ENGINE("calling_engine"),
|
||||
EXTRACTING_CONTENT("extracting_content"),
|
||||
EXECUTING_TOOL("executing_tool"),
|
||||
PROCESSING("processing");
|
||||
PROCESSING("processing"),
|
||||
/**
|
||||
* Generic engine-emitted progress event (e.g. chunked-reasoner slice progress). The original
|
||||
* engine event JSON is carried in {@link AiWorkflowProgressEvent#getEngineDetail()}, including
|
||||
* a specific {@code phase} string the frontend can switch on.
|
||||
*/
|
||||
ENGINE_PROGRESS("engine_progress");
|
||||
|
||||
private final String value;
|
||||
|
||||
|
||||
+22
-2
@@ -23,8 +23,17 @@ public class AiWorkflowProgressEvent {
|
||||
/** Total number of plan steps, for {@link AiWorkflowPhase#EXECUTING_TOOL} events. */
|
||||
private Integer stepCount;
|
||||
|
||||
/**
|
||||
* Engine-emitted event payload, for {@link AiWorkflowPhase#ENGINE_PROGRESS} events. The payload
|
||||
* is a typed subtype keyed on its {@code phase} string (e.g. {@code "whole_doc_slice_done"})
|
||||
* carrying phase-specific fields (slice index, page range, durations, etc.) that the frontend
|
||||
* can render as detailed progress.
|
||||
*/
|
||||
private AiEngineProgressDetail engineDetail;
|
||||
|
||||
public static AiWorkflowProgressEvent of(AiWorkflowPhase phase) {
|
||||
return new AiWorkflowProgressEvent(phase, System.currentTimeMillis(), null, null, null);
|
||||
return new AiWorkflowProgressEvent(
|
||||
phase, System.currentTimeMillis(), null, null, null, null);
|
||||
}
|
||||
|
||||
public static AiWorkflowProgressEvent executingTool(String tool, int stepIndex, int stepCount) {
|
||||
@@ -33,6 +42,17 @@ public class AiWorkflowProgressEvent {
|
||||
System.currentTimeMillis(),
|
||||
tool,
|
||||
stepIndex,
|
||||
stepCount);
|
||||
stepCount,
|
||||
null);
|
||||
}
|
||||
|
||||
public static AiWorkflowProgressEvent engineProgress(AiEngineProgressDetail detail) {
|
||||
return new AiWorkflowProgressEvent(
|
||||
AiWorkflowPhase.ENGINE_PROGRESS,
|
||||
System.currentTimeMillis(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
detail);
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -8,6 +8,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Structured AI workflow result")
|
||||
public class AiWorkflowResponse {
|
||||
@@ -71,6 +73,12 @@ public class AiWorkflowResponse {
|
||||
@Schema(description = "Per-file text extraction requests from the AI engine")
|
||||
private List<AiWorkflowFileRequest> files = new ArrayList<>();
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Files the AI engine requires to be ingested into RAG before it can continue"
|
||||
+ " the workflow. Populated on need_ingest outcomes.")
|
||||
private List<AiFile> filesToIngest = new ArrayList<>();
|
||||
|
||||
@Schema(description = "Maximum number of pages the AI engine wants text extracted from")
|
||||
private Integer maxPages;
|
||||
|
||||
@@ -79,4 +87,12 @@ public class AiWorkflowResponse {
|
||||
|
||||
@Schema(description = "AI engine capability to resume with on the next turn")
|
||||
private String resumeWith;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Optional structured report from the tool (e.g. math-auditor Verdict, PDF"
|
||||
+ " comment-agent summary). Tools surface this either via a JSON response"
|
||||
+ " body or via the X-Stirling-Tool-Report header. May be null for tools"
|
||||
+ " that produce only a file.")
|
||||
private JsonNode report;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user