Merge branch 'main' into per-user-profile-pictures
@@ -1,7 +1,7 @@
|
||||
# CI routing infra. Editing the top-level router (build.yml) or this filter
|
||||
# config re-runs every area's jobs, so every job-gating filter below includes
|
||||
# *ci. That makes a change to how jobs are dispatched actually exercise those
|
||||
# jobs (self-testing), instead of a router edit only matching the project filter.
|
||||
# CI routing infrastructure. Changes to the top-level router (build.yml) or
|
||||
# this filter configuration rerun every area's jobs. Every job-gating filter
|
||||
# therefore includes *ci, so routing changes exercise the jobs they affect
|
||||
# instead of matching only the project filter.
|
||||
ci: &ci
|
||||
- .github/workflows/build.yml
|
||||
- .github/config/.files.yaml
|
||||
@@ -24,9 +24,9 @@ openapi: &openapi
|
||||
docker-base: &docker-base
|
||||
- docker/base/Dockerfile
|
||||
|
||||
# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch
|
||||
# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a
|
||||
# Dockerfile itself changes, not on every code PR.
|
||||
# Dockerfiles only (base, embedded, and unoserver). The slow multi-architecture
|
||||
# (arm64) leg of the PR Docker test build runs only when a Dockerfile changes,
|
||||
# rather than for every code PR.
|
||||
dockerfiles: &dockerfiles
|
||||
- docker/**/Dockerfile*
|
||||
|
||||
@@ -68,7 +68,6 @@ project: &project
|
||||
frontend: &frontend
|
||||
- *ci
|
||||
- frontend/**
|
||||
- .github/workflows/testdriver.yml
|
||||
- testing/**
|
||||
- docker/**
|
||||
- scripts/translations/*.py
|
||||
@@ -88,8 +87,8 @@ frontend: &frontend
|
||||
- .github/workflows/e2e-stubbed.yml
|
||||
- .github/workflows/e2e-live.yml
|
||||
|
||||
# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
|
||||
# job on changes to any of these.
|
||||
# Files that affect the Tauri desktop bundle. Changes to any of these files
|
||||
# trigger the multi-OS Tauri build job.
|
||||
tauri: &tauri
|
||||
- *ci
|
||||
- frontend/editor/src-tauri/**
|
||||
@@ -102,9 +101,9 @@ tauri: &tauri
|
||||
- Taskfile.yml
|
||||
- .taskfiles/desktop.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.
|
||||
# Files that affect the AI engine, including its Python tool models, fixers,
|
||||
# and tests. The engine validation job also runs when the Java tool surfaces
|
||||
# used to generate those models change.
|
||||
engine: &engine
|
||||
- *ci
|
||||
- engine/**
|
||||
@@ -114,10 +113,10 @@ engine: &engine
|
||||
- .taskfiles/engine.yml
|
||||
|
||||
# Files that can make the committed generated API models (frontend tool API
|
||||
# types + engine tool models) go stale: the Java tool surfaces they derive from,
|
||||
# the generators, the generated files themselves (to catch a hand-edit), and the
|
||||
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
|
||||
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
|
||||
# types and engine tool models) stale: their Java sources, generators,
|
||||
# generated outputs (to catch hand edits), and generation tasks. Broad
|
||||
# frontend, Docker, and testing globs are intentionally excluded, so a CSS-only
|
||||
# PR does not start the backend to rebuild the specification.
|
||||
generated-models: &generated-models
|
||||
- *ci
|
||||
- *openapi
|
||||
@@ -141,8 +140,8 @@ licenses-backend: &licenses-backend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
- *build
|
||||
|
||||
# Files that can affect premium / enterprise behaviour. Gate the enterprise
|
||||
# Playwright job on changes to any of these on PRs.
|
||||
# Files that can affect premium or enterprise behaviour. Changes to any of
|
||||
# these files trigger the enterprise Playwright job for pull requests.
|
||||
proprietary: &proprietary
|
||||
- *ci
|
||||
- app/proprietary/**
|
||||
|
||||
@@ -11,6 +11,8 @@ updates:
|
||||
- "/app/common"
|
||||
- "/app/core"
|
||||
- "/app/proprietary"
|
||||
- "/app/saas"
|
||||
- "/buildSrc"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
|
||||
@@ -26,6 +26,10 @@ jobs:
|
||||
check-pr:
|
||||
if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
# Only reads the PR via pulls.get with the default GITHUB_TOKEN.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
should_deploy: ${{ steps.decide.outputs.should_deploy }}
|
||||
is_fork: ${{ steps.resolve.outputs.is_fork }}
|
||||
@@ -35,7 +39,7 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -97,6 +101,7 @@ jobs:
|
||||
echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy-v2-pr:
|
||||
environment: pr-preview
|
||||
needs: check-pr
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true')
|
||||
@@ -107,6 +112,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
packages: write
|
||||
pull-requests: write
|
||||
env:
|
||||
# Single source of truth for whether this preview embeds the admin portal:
|
||||
@@ -115,7 +121,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -125,20 +131,11 @@ jobs:
|
||||
repository: ${{ github.repository }}
|
||||
ref: main
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Add deployment started comment
|
||||
id: deployment-started
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
|
||||
@@ -180,7 +177,8 @@ jobs:
|
||||
with:
|
||||
repository: ${{ needs.check-pr.outputs.pr_repository }}
|
||||
ref: ${{ needs.check-pr.outputs.pr_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# untrusted tree is built below - never leave credentials in .git/config
|
||||
persist-credentials: false
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -192,11 +190,16 @@ jobs:
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
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: Get commit hash for app
|
||||
id: commit-hash
|
||||
@@ -220,7 +223,7 @@ jobs:
|
||||
- name: Check if image exists
|
||||
id: check-image
|
||||
run: |
|
||||
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} >/dev/null 2>&1; then
|
||||
if docker manifest inspect ${IMAGE_BASE}:v2-${{ steps.commit-hash.outputs.app_short }} >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Image already exists, skipping build"
|
||||
else
|
||||
@@ -228,6 +231,8 @@ jobs:
|
||||
echo "Image needs to be built"
|
||||
fi
|
||||
|
||||
env:
|
||||
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
|
||||
- name: Build and push V2 image
|
||||
if: steps.check-image.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
@@ -237,7 +242,7 @@ jobs:
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
@@ -246,9 +251,11 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${NEW_VPS_SSH_KEY}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
env:
|
||||
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
|
||||
- name: Deploy V2 to VPS
|
||||
id: deploy
|
||||
run: |
|
||||
@@ -261,7 +268,7 @@ jobs:
|
||||
services:
|
||||
stirling-pdf-v2:
|
||||
container_name: stirling-pdf-v2-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
image: ${IMAGE_BASE}:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
ports:
|
||||
- "${V2_PORT}:8080"
|
||||
volumes:
|
||||
@@ -273,8 +280,8 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
|
||||
SECURITY_INITIALLOGIN_USERNAME: "${TEST_LOGIN_USERNAME}"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "${TEST_LOGIN_PASSWORD}"
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}"
|
||||
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Embedded Architecture"
|
||||
@@ -288,9 +295,9 @@ jobs:
|
||||
EOF
|
||||
|
||||
# Deploy to VPS
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose-v2.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose-v2.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH
|
||||
# Create V2 PR-specific directories
|
||||
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs,storage}
|
||||
|
||||
@@ -315,12 +322,19 @@ jobs:
|
||||
# Set port for output
|
||||
echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT
|
||||
|
||||
env:
|
||||
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
|
||||
TEST_LOGIN_USERNAME: ${{ secrets.TEST_LOGIN_USERNAME }}
|
||||
TEST_LOGIN_PASSWORD: ${{ secrets.TEST_LOGIN_PASSWORD }}
|
||||
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
|
||||
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
|
||||
# ---- Storybook preview (only when this PR touches stories/.storybook) ----
|
||||
# Runs inside the same approved-contributor-gated deploy job, so it deploys
|
||||
# under the exact same access rules as the app preview.
|
||||
- name: Detect Storybook changes
|
||||
id: sb-changes
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
with:
|
||||
list-files: json
|
||||
filters: |
|
||||
@@ -379,8 +393,9 @@ jobs:
|
||||
env:
|
||||
SB_URL: ${{ steps.storybook.outputs.url }}
|
||||
SB_FILES: ${{ steps.sb-changes.outputs.storybook_files }}
|
||||
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
|
||||
@@ -401,7 +416,7 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
|
||||
const deploymentUrl = `http://${process.env.NEW_VPS_HOST}:${v2Port}`;
|
||||
|
||||
// Only mention the portal when this image actually embeds it.
|
||||
// Use the direct IP URL - the SSL hostname isn't supported yet.
|
||||
@@ -447,6 +462,10 @@ jobs:
|
||||
});
|
||||
|
||||
cleanup-v2-deployment:
|
||||
# Tearing a preview down is not a deployment - no deployment object.
|
||||
environment:
|
||||
name: pr-preview
|
||||
deployment: false
|
||||
if: github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -456,26 +475,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clean up V2 deployment comments
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ github.event.pull_request.number }};
|
||||
@@ -504,12 +514,14 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${NEW_VPS_SSH_KEY}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
env:
|
||||
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
|
||||
- name: Cleanup V2 deployment
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << 'ENDSSH'
|
||||
if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then
|
||||
echo "Found V2 PR directory, proceeding with cleanup..."
|
||||
|
||||
@@ -542,6 +554,9 @@ jobs:
|
||||
# Only remove PR-specific containers and directories
|
||||
ENDSSH
|
||||
|
||||
env:
|
||||
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
|
||||
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
@@ -37,7 +37,8 @@ jobs:
|
||||
check-comment:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read # actions/checkout
|
||||
issues: write # add reaction to the triggering issue comment
|
||||
if: |
|
||||
vars.CI_PROFILE != 'lite' && (
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
@@ -69,22 +70,13 @@ jobs:
|
||||
enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Get PR data
|
||||
id: get-pr
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -155,7 +147,7 @@ jobs:
|
||||
id: add-eyes-reaction
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
console.log(`Adding eyes reaction to comment ID: ${context.payload.comment.id}`);
|
||||
try {
|
||||
@@ -174,35 +166,38 @@ jobs:
|
||||
}
|
||||
|
||||
deploy-pr:
|
||||
environment: pr-preview
|
||||
needs: check-comment
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read # actions/checkout, incl. the PR merge ref
|
||||
issues: write # reactions, 'pr-deployed' label, deployment URL comment
|
||||
pull-requests: write
|
||||
packages: write # push PR image to ghcr.io
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
# untrusted tree gets built below - never leave credentials in .git/config
|
||||
persist-credentials: false
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-deploy-pr-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
@@ -210,17 +205,6 @@ jobs:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Run Gradle Command
|
||||
@@ -240,11 +224,16 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
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: Build and push PR-specific image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
@@ -254,7 +243,7 @@ jobs:
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
build-args: |
|
||||
VERSION_TAG=alpha
|
||||
PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }}
|
||||
@@ -269,15 +258,17 @@ jobs:
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-engine
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-engine
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:engine-pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:engine-pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${NEW_VPS_SSH_KEY}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
env:
|
||||
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
|
||||
- name: Deploy to VPS
|
||||
id: deploy
|
||||
run: |
|
||||
@@ -295,11 +286,11 @@ jobs:
|
||||
# Set pro/enterprise settings (enterprise implies pro)
|
||||
if [ "${{ needs.check-comment.outputs.enable_enterprise }}" == "true" ]; then
|
||||
PREMIUM_ENABLED="true"
|
||||
PREMIUM_KEY="${{ secrets.ENTERPRISE_KEY }}"
|
||||
PREMIUM_KEY="${ENTERPRISE_KEY}"
|
||||
PREMIUM_PROFEATURES_AUDIT_ENABLED="true"
|
||||
elif [ "${{ needs.check-comment.outputs.enable_pro }}" == "true" ]; then
|
||||
PREMIUM_ENABLED="true"
|
||||
PREMIUM_KEY="${{ secrets.PREMIUM_KEY }}"
|
||||
PREMIUM_KEY="${PRO_KEY}"
|
||||
PREMIUM_PROFEATURES_AUDIT_ENABLED="true"
|
||||
else
|
||||
PREMIUM_ENABLED="false"
|
||||
@@ -309,7 +300,6 @@ jobs:
|
||||
|
||||
ENABLE_PROTOTYPES="${{ needs.check-comment.outputs.enable_prototypes }}"
|
||||
PR_NUMBER="${{ needs.check-comment.outputs.pr_number }}"
|
||||
DOCKER_USER="${{ secrets.DOCKER_HUB_USERNAME }}"
|
||||
|
||||
# Build engine env vars for backend (only set when prototypes enabled)
|
||||
if [ "$ENABLE_PROTOTYPES" == "true" ]; then
|
||||
@@ -319,9 +309,9 @@ jobs:
|
||||
ENGINE_SERVICE="
|
||||
stirling-pdf-engine:
|
||||
container_name: stirling-pdf-engine-pr-${PR_NUMBER}
|
||||
image: ${DOCKER_USER}/test:engine-pr-${PR_NUMBER}
|
||||
image: ${IMAGE_BASE}:engine-pr-${PR_NUMBER}
|
||||
environment:
|
||||
ANTHROPIC_API_KEY: \"${{ secrets.ANTHROPIC_API_KEY }}\"
|
||||
ANTHROPIC_API_KEY: \"${ANTHROPIC_API_KEY}\"
|
||||
networks:
|
||||
- pr-network
|
||||
restart: on-failure:5"
|
||||
@@ -344,7 +334,7 @@ jobs:
|
||||
services:
|
||||
stirling-pdf:
|
||||
container_name: stirling-pdf-pr-${PR_NUMBER}
|
||||
image: ${DOCKER_USER}/test:pr-${PR_NUMBER}
|
||||
image: ${IMAGE_BASE}:pr-${PR_NUMBER}
|
||||
ports:
|
||||
- "${PR_NUMBER}:8080"
|
||||
volumes:
|
||||
@@ -368,9 +358,9 @@ jobs:
|
||||
EOF
|
||||
|
||||
# Then copy the file and execute commands
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH
|
||||
# Create PR-specific directories
|
||||
mkdir -p /stirling/PR-${PR_NUMBER}/{data,config,logs}
|
||||
|
||||
@@ -386,11 +376,19 @@ jobs:
|
||||
# Set output for use in PR comment
|
||||
echo "security_status=${SECURITY_STATUS}" >> $GITHUB_ENV
|
||||
|
||||
env:
|
||||
ENTERPRISE_KEY: ${{ secrets.ENTERPRISE_KEY }}
|
||||
# named PRO_KEY, not PREMIUM_KEY, so the shell var it feeds is not self-referential
|
||||
PRO_KEY: ${{ secrets.PREMIUM_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
|
||||
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
|
||||
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
- name: Add success reaction to comment
|
||||
if: success() && github.event_name == 'issue_comment'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
console.log(`Adding rocket reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`);
|
||||
try {
|
||||
@@ -425,7 +423,7 @@ jobs:
|
||||
if: failure() && github.event_name == 'issue_comment'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
console.log(`Adding -1 reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`);
|
||||
try {
|
||||
@@ -444,15 +442,17 @@ jobs:
|
||||
- name: Post deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const { GITHUB_REPOSITORY } = process.env;
|
||||
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
|
||||
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
|
||||
const securityStatus = process.env.security_status || "Security Disabled";
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${prNumber}`;
|
||||
const deploymentUrl = `http://${process.env.NEW_VPS_HOST}:${prNumber}`;
|
||||
const commentBody = `## 🚀 PR Test Deployment\n\n` +
|
||||
`Your PR has been deployed for testing!\n\n` +
|
||||
`🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` +
|
||||
@@ -477,26 +477,22 @@ jobs:
|
||||
handle-label-commands:
|
||||
if: ${{ github.event.issue.pull_request != null }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # actions/checkout, reads repo_devs.json and labels.yml
|
||||
issues: write # add/remove labels, delete the command comment
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Apply label commands
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -7,41 +7,33 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
|
||||
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
# Tearing a preview down is not a deployment - no deployment object.
|
||||
environment:
|
||||
name: pr-preview
|
||||
deployment: false
|
||||
if: github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # actions/checkout
|
||||
pull-requests: write
|
||||
issues: write
|
||||
issues: write # list/remove labels, list/delete comments
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Remove 'pr-deployed' label if present
|
||||
id: remove-label-comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const prNumber = ${{ github.event.pull_request.number }};
|
||||
const owner = context.repo.owner;
|
||||
@@ -100,14 +92,22 @@ jobs:
|
||||
if: steps.remove-label-comment.outputs.present == 'true'
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${NEW_VPS_SSH_KEY}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
env:
|
||||
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cleanup PR deployment
|
||||
if: steps.remove-label-comment.outputs.present == 'true'
|
||||
id: cleanup
|
||||
# ENDSSH heredoc is quoted, so its body is sent literally: secrets inside it
|
||||
# must stay as GitHub expressions, a shell var would be empty on the remote host.
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << 'ENDSSH'
|
||||
if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then
|
||||
echo "Found PR directory, proceeding with cleanup..."
|
||||
|
||||
@@ -122,8 +122,8 @@ jobs:
|
||||
rm -rf /stirling/PR-${{ github.event.pull_request.number }}
|
||||
|
||||
# Remove the Docker images
|
||||
docker rmi --no-prune ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ github.event.pull_request.number }} || true
|
||||
docker rmi --no-prune ${{ secrets.DOCKER_HUB_USERNAME }}/test:engine-pr-${{ github.event.pull_request.number }} || true
|
||||
docker rmi --no-prune ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:pr-${{ github.event.pull_request.number }} || true
|
||||
docker rmi --no-prune ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:engine-pr-${{ github.event.pull_request.number }} || true
|
||||
|
||||
echo "PERFORMED_CLEANUP"
|
||||
else
|
||||
@@ -131,6 +131,9 @@ jobs:
|
||||
echo "NO_CLEANUP_NEEDED"
|
||||
fi
|
||||
ENDSSH
|
||||
env:
|
||||
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
|
||||
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
is_fork: ${{ steps.decide.outputs.is_fork }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
name: AI - PR Title Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
branches: [main]
|
||||
|
||||
permissions: # required for secure-repo hardening
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ai-title-review:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
models: read
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git to suppress detached HEAD warning
|
||||
run: git config --global advice.detachedHead false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Check if actor is repo developer
|
||||
id: actor
|
||||
run: |
|
||||
if [[ "${{ github.actor }}" == *"[bot]" ]]; then
|
||||
echo "PR opened by a bot – skipping AI title review."
|
||||
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f .github/config/repo_devs.json ]; then
|
||||
echo "Error: .github/config/repo_devs.json not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Validate JSON and extract repo_devs
|
||||
REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; })
|
||||
# Convert developer list into Bash array
|
||||
mapfile -t DEVS_ARRAY <<< "$REPO_DEVS"
|
||||
if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then
|
||||
echo "is_repo_dev=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Get PR diff
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: get_diff
|
||||
run: |
|
||||
git fetch origin ${{ github.base_ref }}
|
||||
git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff
|
||||
echo "diff<<EOF" >> $GITHUB_OUTPUT
|
||||
cat pr.diff >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check and sanitize PR title
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: sanitize_pr_title
|
||||
env:
|
||||
PR_TITLE_RAW: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
# Sanitize PR title: max 72 characters, only printable characters
|
||||
PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g')
|
||||
if [[ ${#PR_TITLE} -lt 5 ]]; then
|
||||
echo "PR title is too short. Must be at least 5 characters." >&2
|
||||
fi
|
||||
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: AI PR Title Analysis
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: ai-title-analysis
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt-file: ".github/config/system-prompt.txt"
|
||||
prompt: |
|
||||
Based on the following input data:
|
||||
|
||||
{
|
||||
"diff": "${{ steps.get_diff.outputs.diff }}",
|
||||
"pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}"
|
||||
}
|
||||
|
||||
Respond ONLY with valid JSON in the format:
|
||||
{
|
||||
"improved_rating": <0-10>,
|
||||
"improved_ai_title_rating": <0-10>,
|
||||
"improved_title": "<ai generated title>"
|
||||
}
|
||||
|
||||
- name: Validate and set SCRIPT_OUTPUT
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
run: |
|
||||
cat <<EOF > ai_response.json
|
||||
${{ steps.ai-title-analysis.outputs.response }}
|
||||
EOF
|
||||
|
||||
# Validate JSON structure
|
||||
jq -e '
|
||||
(keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and
|
||||
(.improved_rating | type == "number" and . >= 0 and . <= 10) and
|
||||
(.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and
|
||||
(.improved_title | type == "string")
|
||||
' ai_response.json
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Invalid AI response format" >&2
|
||||
cat ai_response.json >&2
|
||||
exit 1
|
||||
fi
|
||||
# Parse JSON fields
|
||||
IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json)
|
||||
IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json)
|
||||
# Limit comment length to 1000 characters
|
||||
COMMENT=$(cat <<EOF
|
||||
## 🤖 AI PR Title Suggestion
|
||||
|
||||
**PR-Title Rating**: $IMPROVED_RATING/10
|
||||
|
||||
### ⬇️ Suggested Title (copy & paste):
|
||||
|
||||
\`\`\`
|
||||
$IMPROVED_TITLE
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
*Generated by GitHub Models AI*
|
||||
EOF
|
||||
)
|
||||
echo "$COMMENT" > /tmp/ai-title-comment.md
|
||||
# Log input and output to the GitHub Step Summary
|
||||
echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```bash' >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY
|
||||
echo '```json' >> $GITHUB_STEP_SUMMARY
|
||||
cat ai_response.json >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Post comment on PR if needed
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8');
|
||||
const { GITHUB_REPOSITORY } = process.env;
|
||||
const [owner, repo] = GITHUB_REPOSITORY.split('/');
|
||||
const issue_number = context.issue.number;
|
||||
|
||||
const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/);
|
||||
const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null;
|
||||
|
||||
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
|
||||
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
|
||||
|
||||
const existing = comments.data.find(c =>
|
||||
c.user?.login === expectedActor &&
|
||||
c.body.includes("## 🤖 AI PR Title Suggestion")
|
||||
);
|
||||
|
||||
if (rating === null) {
|
||||
console.log("No rating found in AI response – skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (rating <= 5) {
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo,
|
||||
comment_id: existing.id,
|
||||
body
|
||||
});
|
||||
console.log("Updated existing suggestion comment.");
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number,
|
||||
body
|
||||
});
|
||||
console.log("Created new suggestion comment.");
|
||||
}
|
||||
} else {
|
||||
const praise = `## 🤖 AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\n✅ No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo,
|
||||
comment_id: existing.id,
|
||||
body: praise
|
||||
});
|
||||
console.log("Replaced suggestion with praise.");
|
||||
} else {
|
||||
console.log("Rating > 5 and no existing comment – skipping comment.");
|
||||
}
|
||||
}
|
||||
|
||||
- name: is not repo dev
|
||||
if: steps.actor.outputs.is_repo_dev != 'true'
|
||||
run: |
|
||||
exit 0 # Skip the AI title review for non-repo developers
|
||||
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: |
|
||||
rm -f pr.diff ai_response.json /tmp/ai-title-comment.md
|
||||
echo "Cleaned up temporary files."
|
||||
continue-on-error: true # Ensure cleanup runs even if previous steps fail
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -66,11 +66,12 @@ jobs:
|
||||
echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
publish-aur:
|
||||
environment: package-publish
|
||||
needs: get-release-info
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -13,26 +13,21 @@ jobs:
|
||||
labeler:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read # checkout + labeler fetching its config from the repo
|
||||
pull-requests: write # read changed files, apply labels to the PR
|
||||
issues: write # labels are applied through the issues API
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: srvaroa/labeler@bf262763a8a8e191f5847873aecc0f29df84f957 # v1.14.0
|
||||
with:
|
||||
config_path: .github/labeler-config-srvaroa.yml
|
||||
use_local_config: false
|
||||
fail_on_error: true
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ steps.setup-bot.outputs.token }}"
|
||||
GITHUB_TOKEN: "${{ github.token }}"
|
||||
|
||||
@@ -20,6 +20,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -28,29 +31,26 @@ jobs:
|
||||
flavor: [core, proprietary, saas]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK ${{ matrix.jdk-version }}
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Check Java formatting (Spotless)
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
STIRLING_FLAVOR: ${{ matrix.flavor }}
|
||||
# Configure the Gradle daemon explicitly; GRADLE_OPTS alone only
|
||||
# configures the Gradle client JVM.
|
||||
GRADLE_OPTS: '-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC'
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC"
|
||||
|
||||
- name: Check Test Reports Exist
|
||||
if: always()
|
||||
|
||||
@@ -15,6 +15,11 @@ name: Enterprise E2E (Playwright)
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
use_shared_cache:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
@@ -37,6 +42,9 @@ jobs:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
playwright-e2e-enterprise:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
needs: pick
|
||||
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
|
||||
# so the suite can't boot premium and would fail. See the header comment.
|
||||
@@ -50,26 +58,36 @@ jobs:
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
if: inputs.use_shared_cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Cache Gradle User Home
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Restore cache Gradle
|
||||
if: inputs.use_shared_cache == false
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-playwright-e2e-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -309,6 +327,9 @@ jobs:
|
||||
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
|
||||
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
|
||||
multinode-e2e:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
needs: [pick, playwright-e2e-enterprise]
|
||||
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
|
||||
if: >-
|
||||
@@ -324,7 +345,7 @@ jobs:
|
||||
MN_COMPOSE: docker-compose-multinode.yml
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -48,53 +48,75 @@ jobs:
|
||||
proprietary: ${{ steps.changes.outputs.proprietary }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
|
||||
gradle-cache-prime:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
name: Prime shared Gradle cache
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
|
||||
- name: Calculate Gradle cache key
|
||||
id: gradle-cache-key
|
||||
shell: bash
|
||||
run: |
|
||||
echo "key=gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Gradle (lookup-only)
|
||||
id: cache-gradle-restore
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: ${{ steps.gradle-cache-key.outputs.key }}
|
||||
lookup-only: true
|
||||
|
||||
- name: Set up JDK 25
|
||||
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Resolve backend dependencies
|
||||
run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon
|
||||
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
|
||||
run: ./gradlew :stirling-pdf:classes --no-daemon
|
||||
env:
|
||||
STIRLING_FLAVOR: saas
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Save cache Gradle User Home
|
||||
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ steps.gradle-cache-key.outputs.key }}
|
||||
|
||||
build:
|
||||
needs: [files-changed, gradle-cache-prime]
|
||||
permissions:
|
||||
@@ -134,9 +156,10 @@ jobs:
|
||||
uses: ./.github/workflows/frontend-validation.yml
|
||||
secrets: inherit
|
||||
|
||||
# Advisory: deliberately NOT in all-checks-passed. It reports on the stories a
|
||||
# branch touches so a regression is visible in review, but a browser scan is
|
||||
# too new here to block merges on. Promote it once its pass/fail proves stable.
|
||||
# Required (in all-checks-passed). Scans the stories a branch touches in both
|
||||
# light and dark; an axe violation in either theme blocks the merge. The
|
||||
# whole-suite sweep (nightly.yml) still covers stories a change affects without
|
||||
# touching them directly.
|
||||
frontend-a11y:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: [files-changed]
|
||||
@@ -168,6 +191,8 @@ jobs:
|
||||
contents: read
|
||||
uses: ./.github/workflows/build-enterprise.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
use_shared_cache: true
|
||||
|
||||
check-licence:
|
||||
if: needs.files-changed.outputs.build == 'true'
|
||||
@@ -191,7 +216,14 @@ jobs:
|
||||
|
||||
test-build-docker-images:
|
||||
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
|
||||
needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime]
|
||||
needs:
|
||||
[
|
||||
files-changed,
|
||||
build,
|
||||
check-generateOpenApiDocs,
|
||||
check-licence,
|
||||
gradle-cache-prime,
|
||||
]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
@@ -203,7 +235,7 @@ jobs:
|
||||
|
||||
tauri-build:
|
||||
if: needs.files-changed.outputs.tauri == 'true'
|
||||
needs: [files-changed]
|
||||
needs: [files-changed, gradle-cache-prime]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -217,6 +249,7 @@ jobs:
|
||||
with:
|
||||
platform: windows-macos
|
||||
sign: true
|
||||
use_shared_cache: true
|
||||
|
||||
ai-engine:
|
||||
if: needs.files-changed.outputs.engine == 'true'
|
||||
@@ -240,6 +273,8 @@ jobs:
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/check-generated-models.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
use_shared_cache: true
|
||||
|
||||
pre-commit:
|
||||
needs: [files-changed]
|
||||
@@ -290,6 +325,7 @@ jobs:
|
||||
- db-migration-test
|
||||
- check-generateOpenApiDocs
|
||||
- frontend-validation
|
||||
- frontend-a11y
|
||||
- playwright-e2e
|
||||
- playwright-e2e-live
|
||||
- playwright-e2e-enterprise
|
||||
@@ -304,7 +340,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -316,6 +352,7 @@ jobs:
|
||||
db-migration-test=${{ needs.db-migration-test.result }}
|
||||
check-generateOpenApiDocs=${{ needs.check-generateOpenApiDocs.result }}
|
||||
frontend-validation=${{ needs.frontend-validation.result }}
|
||||
frontend-a11y=${{ needs.frontend-a11y.result }}
|
||||
playwright-e2e=${{ needs.playwright-e2e.result }}
|
||||
playwright-e2e-live=${{ needs.playwright-e2e-live.result }}
|
||||
playwright-e2e-enterprise=${{ needs.playwright-e2e-enterprise.result }}
|
||||
|
||||
@@ -9,6 +9,11 @@ name: Check generated models
|
||||
# post-merge safety net.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
use_shared_cache:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -23,7 +28,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -39,22 +44,29 @@ jobs:
|
||||
engine/uv.lock
|
||||
cache-suffix: generated-models
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
- name: Restore cache Gradle User Home
|
||||
if: inputs.use_shared_cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Restore cache Gradle
|
||||
if: inputs.use_shared_cache == false
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-generated-models-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
||||
@@ -10,33 +10,33 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-licence:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Check licenses for compatibility
|
||||
|
||||
@@ -11,33 +11,33 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-generate-openapi-docs:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Generate OpenAPI documentation
|
||||
|
||||
@@ -23,29 +23,23 @@ jobs:
|
||||
if: github.event_name == 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # Checkout, and read translation files via the contents API
|
||||
issues: write # Allow posting comments on issues/PRs
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main branch first
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Get PR data
|
||||
id: get-pr-data
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const repoOwner = context.payload.repository.owner.login;
|
||||
@@ -66,17 +60,18 @@ jobs:
|
||||
- name: Fetch PR changed files
|
||||
id: fetch-pr-changes
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ steps.get-pr-data.outputs.pr_number }}
|
||||
run: |
|
||||
echo "Fetching PR changed files..."
|
||||
echo "Getting list of changed files from PR..."
|
||||
# Check if PR number exists
|
||||
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
|
||||
if [ -z "${PR_NUMBER}" ]; then
|
||||
echo "Error: PR number is empty"
|
||||
exit 1
|
||||
fi
|
||||
# Get changed files and filter for TOML translation files
|
||||
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/editor/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
|
||||
gh pr view "${PR_NUMBER}" --json files -q ".files[].path" | grep -E '^frontend/editor/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
|
||||
# Check if any files were found
|
||||
if [ ! -s changed_files.txt ]; then
|
||||
echo "No TOML translation files changed in this PR"
|
||||
@@ -88,32 +83,36 @@ jobs:
|
||||
- name: Determine reference file
|
||||
id: determine-file
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
# Untrusted, fork-controlled values are passed via env, never interpolated into the script
|
||||
PR_NUMBER: ${{ steps.get-pr-data.outputs.pr_number }}
|
||||
REPO_OWNER: ${{ steps.get-pr-data.outputs.repo_owner }}
|
||||
REPO_NAME: ${{ steps.get-pr-data.outputs.repo_name }}
|
||||
PR_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
PR_REPO_NAME: ${{ github.event.pull_request.head.repo.name }}
|
||||
PR_BRANCH: ${{ steps.get-pr-data.outputs.branch }}
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const prNumber = ${{ steps.get-pr-data.outputs.pr_number }};
|
||||
const repoOwner = "${{ steps.get-pr-data.outputs.repo_owner }}";
|
||||
const repoName = "${{ steps.get-pr-data.outputs.repo_name }}";
|
||||
|
||||
const prRepoOwner = "${{ github.event.pull_request.head.repo.owner.login }}";
|
||||
const prRepoName = "${{ github.event.pull_request.head.repo.name }}";
|
||||
const branch = "${{ steps.get-pr-data.outputs.branch }}";
|
||||
|
||||
console.log(`Determining reference file for PR #${prNumber}`);
|
||||
|
||||
// Validate inputs
|
||||
// Validate inputs before any use
|
||||
const validateInput = (input, regex, name) => {
|
||||
if (!regex.test(input)) {
|
||||
if (typeof input !== "string" || !regex.test(input)) {
|
||||
throw new Error(`Invalid ${name}: ${input}`);
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
validateInput(repoOwner, /^[a-zA-Z0-9_-]+$/, "repository owner");
|
||||
validateInput(repoName, /^[a-zA-Z0-9._-]+$/, "repository name");
|
||||
validateInput(branch, /^[a-zA-Z0-9._/-]+$/, "branch name");
|
||||
const repoOwner = validateInput(process.env.REPO_OWNER, /^[a-zA-Z0-9_-]+$/, "repository owner");
|
||||
const repoName = validateInput(process.env.REPO_NAME, /^[a-zA-Z0-9._-]+$/, "repository name");
|
||||
const prRepoOwner = validateInput(process.env.PR_REPO_OWNER, /^[a-zA-Z0-9_-]+$/, "PR repository owner");
|
||||
const prRepoName = validateInput(process.env.PR_REPO_NAME, /^[a-zA-Z0-9._-]+$/, "PR repository name");
|
||||
const branch = validateInput(process.env.PR_BRANCH, /^[a-zA-Z0-9._/-]+$/, "branch name");
|
||||
const prNumber = Number(validateInput(process.env.PR_NUMBER, /^[0-9]+$/, "PR number"));
|
||||
|
||||
console.log(`Determining reference file for PR #${prNumber}`);
|
||||
|
||||
// Get the list of changed files in the PR
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
@@ -126,7 +125,7 @@ jobs:
|
||||
const changedFiles = files
|
||||
.filter(file =>
|
||||
file.status !== "removed" &&
|
||||
/^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
|
||||
/^frontend\/editor\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
|
||||
)
|
||||
.map(file => file.filename);
|
||||
|
||||
@@ -209,10 +208,12 @@ jobs:
|
||||
|
||||
- name: Run Python script to check files
|
||||
id: run-check
|
||||
env:
|
||||
PR_ACTOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
echo "Running Python script to check TOML files..."
|
||||
uv run --project engine --locked --group tools python .github/scripts/check_language_toml.py \
|
||||
--actor ${{ github.event.pull_request.user.login }} \
|
||||
--actor "${PR_ACTOR}" \
|
||||
--reference-file "${REFERENCE_FILE}" \
|
||||
--branch "pr-branch" \
|
||||
--files "${FILES_LIST[@]}" > result.txt
|
||||
@@ -245,7 +246,7 @@ jobs:
|
||||
if: env.SCRIPT_OUTPUT != ''
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const { GITHUB_REPOSITORY, SCRIPT_OUTPUT } = process.env;
|
||||
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
|
||||
@@ -261,7 +262,7 @@ jobs:
|
||||
const comment = comments.data.find(c => c.body.includes("## 🌐 TOML Translation Verification Summary"));
|
||||
|
||||
// Only update or create comments by the action user
|
||||
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
|
||||
const expectedActor = "github-actions[bot]";
|
||||
|
||||
if (comment && comment.user.login === expectedActor) {
|
||||
// Update existing comment
|
||||
|
||||
@@ -34,29 +34,26 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
|
||||
@@ -13,34 +13,34 @@ permissions:
|
||||
|
||||
jobs:
|
||||
migration-test:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: 25
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
# Keep the normal formatting path here so this smoke test exercises the
|
||||
# same Gradle configuration as the backend build.
|
||||
- name: Build Stirling-PDF JAR
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
name: Auto V2 Deploy on Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- V2
|
||||
- deploy-on-v2-commit
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy-v2-on-push:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: deploy-v2-push-V2
|
||||
cancel-in-progress: true
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Get commit hashes for frontend and backend
|
||||
id: commit-hashes
|
||||
run: |
|
||||
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
|
||||
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$FRONTEND_HASH" ]; then
|
||||
FRONTEND_HASH="no-frontend-changes"
|
||||
fi
|
||||
|
||||
# Get last commit that touched backend code, docker/backend, or docker/compose
|
||||
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$BACKEND_HASH" ]; then
|
||||
BACKEND_HASH="no-backend-changes"
|
||||
fi
|
||||
|
||||
echo "Frontend hash: $FRONTEND_HASH"
|
||||
echo "Backend hash: $BACKEND_HASH"
|
||||
|
||||
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
|
||||
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Short hashes for tags
|
||||
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
|
||||
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
|
||||
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check if frontend image exists
|
||||
id: check-frontend
|
||||
run: |
|
||||
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Frontend image already exists, skipping build"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "Frontend image needs to be built"
|
||||
fi
|
||||
|
||||
- name: Check if backend image exists
|
||||
id: check-backend
|
||||
run: |
|
||||
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Backend image already exists, skipping build"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "Backend image needs to be built"
|
||||
fi
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-v2-frontend
|
||||
cache-to: type=gha,mode=max,scope=stirling-v2-frontend
|
||||
tags: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-v2-backend
|
||||
cache-to: type=gha,mode=max,scope=stirling-v2-backend
|
||||
tags: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS on port 3000
|
||||
run: |
|
||||
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
|
||||
|
||||
cat > $UNIQUE_NAME << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
backend:
|
||||
container_name: stirling-v2-backend
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
ports:
|
||||
- "13000:8080"
|
||||
volumes:
|
||||
- /stirling/V2/data:/usr/share/tessdata:rw
|
||||
- /stirling/V2/config:/configs:rw
|
||||
- /stirling/V2/logs:/logs:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "true"
|
||||
SECURITY_ENABLELOGIN: "false"
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: "Stirling-PDF V2"
|
||||
UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
|
||||
UI_APPNAMENAVBAR: "V2 Deployment"
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "false"
|
||||
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
|
||||
baseUrl: "https://demo.stirlingpdf.cloud"
|
||||
restart: on-failure:5
|
||||
|
||||
frontend:
|
||||
container_name: stirling-v2-frontend
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
ports:
|
||||
- "3000:80"
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:13000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
# Copy to remote with unique name
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/$UNIQUE_NAME
|
||||
|
||||
# SSH and rename/move atomically to avoid interference
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
mkdir -p /stirling/V2/{data,config,logs}
|
||||
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
|
||||
cd /stirling/V2
|
||||
docker-compose down || true
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
docker system prune -af --volumes || true
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
|
||||
ENDSSH
|
||||
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ../private.key
|
||||
@@ -17,6 +17,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
docker-compose-tests:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -25,30 +28,27 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
# When the PR changes the base image, test.sh builds it locally
|
||||
# (stirling-pdf-base:local) into the daemon image store. A buildx
|
||||
# container builder can't see that store, so skip it here and let
|
||||
|
||||
@@ -11,48 +11,33 @@ permissions:
|
||||
|
||||
jobs:
|
||||
playwright-e2e-live:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
# Gradle does not retry 429s, and a cold cache resolving the buildscript
|
||||
# classpath is exactly where Maven Central rate-limits us. Retry it here,
|
||||
# where a failure is cheap, instead of inside the backgrounded bootRun.
|
||||
- name: Prime Gradle dependencies
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if ./gradlew --quiet -PnoSpotless :stirling-pdf:classes; then
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::Gradle dependency resolution failed (attempt $attempt of 3)"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
echo "::error::Gradle could not resolve dependencies after 3 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
project: stubbed-webkit
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -3,17 +3,12 @@ name: Frontend a11y regression gate
|
||||
# Reusable workflow called from build.yml when frontend sources change.
|
||||
#
|
||||
# Scans the stories this branch touches in real Chromium and runs axe against
|
||||
# each. Existing violations are grandfathered in .storybook/a11y-baseline.json;
|
||||
# the check fails on a NEW violation — a story breaking a rule it wasn't already
|
||||
# breaking — or on a story that fails to render at all.
|
||||
# each; the check fails on any axe violation, or on a story that fails to render
|
||||
# at all.
|
||||
#
|
||||
# Only changed stories, because a full sweep is ~30 minutes: far too slow to sit
|
||||
# in front of every merge. The whole suite is scanned nightly instead
|
||||
# (nightly.yml), which catches anything a branch didn't touch.
|
||||
#
|
||||
# Advisory for now: this is not in build.yml's all-checks-passed list, so a
|
||||
# failure reports without blocking. Promote it once a few weeks of runs show the
|
||||
# pass/fail is stable.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
@@ -26,7 +21,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -28,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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -36,12 +36,16 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
|
||||
generate-frontend-license-report:
|
||||
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
|
||||
environment:
|
||||
name: ci-bot
|
||||
deployment: false
|
||||
if: needs.files-changed.outputs.licenses-frontend == 'true'
|
||||
name: Generate Frontend License Report
|
||||
needs: files-changed
|
||||
@@ -52,7 +56,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -316,6 +320,10 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
generate-backend-license-report:
|
||||
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
|
||||
environment:
|
||||
name: ci-bot
|
||||
deployment: false
|
||||
if: needs.files-changed.outputs.licenses-backend == 'true'
|
||||
needs: files-changed
|
||||
name: Generate Backend License Report
|
||||
@@ -326,7 +334,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -344,22 +352,19 @@ jobs:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-license-report-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -45,29 +48,26 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Get version number
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
ALL="$WINDOWS,$WINDOWS_ARM64,$MACOS,$LINUX"
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
case "${INPUT_PLATFORM}" in
|
||||
"windows")
|
||||
echo "matrix={\"include\":[$WINDOWS,$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
@@ -115,7 +115,12 @@ jobs:
|
||||
echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
env:
|
||||
INPUT_PLATFORM: ${{ github.event.inputs.platform }}
|
||||
build-jars:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
needs: determine-matrix
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -135,29 +140,26 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -194,6 +196,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
environment: release-signing
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -201,11 +204,10 @@ jobs:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: >
|
||||
@@ -234,6 +236,14 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
# 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
|
||||
@@ -257,17 +267,6 @@ jobs:
|
||||
java-version: "25"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -292,7 +291,7 @@ jobs:
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
@@ -302,22 +301,22 @@ jobs:
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certBytes = [Convert]::FromBase64String("$env:SM_CLIENT_CERT_FILE_B64")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=$env:SM_HOST" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=$env:SM_API_KEY" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=$env:SM_CLIENT_CERT_PASSWORD" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=$env:SM_KEYPAIR_ALIAS" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
@@ -335,40 +334,14 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
- name: Import Apple Developer Certificate
|
||||
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')
|
||||
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -389,7 +362,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
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')
|
||||
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -405,7 +378,7 @@ jobs:
|
||||
# Without this, signCommand failures are opaque (Tauri captures but drops
|
||||
# smctl's stderr) - running these loudly surfaces auth/env/keypair issues.
|
||||
- name: Preflight smctl
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
|
||||
shell: pwsh
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -436,7 +409,7 @@ jobs:
|
||||
# smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD
|
||||
# from env (set by prior DigiCert setup step). No --config-file needed.
|
||||
- name: Configure Windows code signing
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
|
||||
shell: bash
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -457,7 +430,7 @@ jobs:
|
||||
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json
|
||||
|
||||
- name: Import release GPG signing key (Linux)
|
||||
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')
|
||||
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/release')
|
||||
run: |
|
||||
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
|
||||
gpg --list-secret-keys --keyid-format=long
|
||||
@@ -489,8 +462,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.
|
||||
# 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' }}
|
||||
# Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or the release branch, 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/release')) && '1' || '0' }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -516,7 +489,7 @@ jobs:
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
GPG_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' }}
|
||||
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
|
||||
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
run: |
|
||||
@@ -555,7 +528,7 @@ jobs:
|
||||
echo "Stripped bundled libwayland from $(basename "$AI")"
|
||||
|
||||
- name: Clear release GPG key from runner keyring (Linux)
|
||||
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')
|
||||
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/release')
|
||||
env:
|
||||
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
run: |
|
||||
@@ -570,7 +543,7 @@ jobs:
|
||||
# artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw
|
||||
# cargo output unsigned, so checking it produces false negatives.
|
||||
- name: Verify Windows Code Signature
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
|
||||
timeout-minutes: 15
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -731,7 +704,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -902,11 +875,11 @@ jobs:
|
||||
# workflow_dispatch path requires platform=='all' so a single-platform
|
||||
# dispatch can't overwrite an existing release's full latest.json with a
|
||||
# partial one (action-gh-release defaults overwrite_files:true).
|
||||
# release / V2-master always build the full matrix so no extra guard needed.
|
||||
# release event / release branch always build the full matrix so no extra guard needed.
|
||||
# fail_on_unmatched_files makes a missing latest.json or installer fail loudly
|
||||
# instead of silently shipping a broken auto-update.
|
||||
- name: Upload binaries to Release
|
||||
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
|
||||
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/release'
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
|
||||
@@ -4,6 +4,11 @@ on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *" # 2 AM UTC every night
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/nightly.yml
|
||||
- testing/cucumber/**
|
||||
- docker/embedded/compose/test_cicd.yml
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -18,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -59,13 +64,17 @@ jobs:
|
||||
# the story itself — a shared component, a theme token — still surfaces within
|
||||
# a day.
|
||||
a11y-all-stories:
|
||||
name: a11y (every story, light + dark)
|
||||
name: a11y (every story)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
theme: [light, dark]
|
||||
runs-on: ubuntu-latest
|
||||
# Two full sweeps (one per theme), each ~30 minutes of browser time.
|
||||
timeout-minutes: 120
|
||||
# One full sweep (~30 minutes of browser time).
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -82,14 +91,14 @@ jobs:
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: a11y gate (every story, light + dark)
|
||||
run: task frontend:storybook:a11y
|
||||
- name: a11y gate (every story, ${{ matrix.theme }})
|
||||
run: task frontend:storybook:a11y:${{ matrix.theme }}
|
||||
|
||||
- name: Upload scan reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: a11y-scan-nightly-${{ github.run_id }}
|
||||
name: a11y-scan-nightly-${{ matrix.theme }}-${{ github.run_id }}
|
||||
path: frontend/.a11y-scan/
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
@@ -99,8 +108,13 @@ jobs:
|
||||
|
||||
# Builds all desktop platforms on a schedule so the Rust dependency cache is
|
||||
# written on main, where PR and merge-queue tauri builds can restore it.
|
||||
#
|
||||
# The only job here still pinned to schedule/main: it primes a cache rather than
|
||||
# testing anything, and Actions scopes a cache written on a PR branch to that PR
|
||||
# alone, so a PR run costs three platform builds and produces nothing reusable.
|
||||
warm-tauri-cache:
|
||||
name: Warm Tauri Rust cache
|
||||
if: github.event_name == 'schedule' || github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -109,3 +123,75 @@ jobs:
|
||||
platform: all
|
||||
sign: false
|
||||
secrets: inherit
|
||||
|
||||
# Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run
|
||||
# of every other feature.
|
||||
cucumber-nightly:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
name: Cucumber (nightly scenarios + full concurrency)
|
||||
runs-on: ubuntu-latest
|
||||
# Fork pull requests get no MAVEN_* secrets, so the image build cannot work.
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.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: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Start the fat image with login and storage enabled
|
||||
run: docker compose -f docker/embedded/compose/test_cicd.yml up -d --build
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Wait for the server
|
||||
# Throwaway key from test_cicd.yml; out of the header literal for gitleaks.
|
||||
env:
|
||||
TEST_API_KEY: "123456789"
|
||||
run: |
|
||||
curl --retry 90 --retry-delay 3 --retry-connrefused --retry-all-errors \
|
||||
-sf -H "X-API-KEY: $TEST_API_KEY" http://localhost:8080/api/v1/info/status
|
||||
|
||||
# Heavy LibreOffice/Calibre/Ghostscript conversions, excluded from the PR run.
|
||||
# Both tasks install the behave deps themselves, so there is no separate uv sync step.
|
||||
- name: Run @nightly scenarios
|
||||
run: task cucumber:nightly
|
||||
|
||||
# Genuinely different payloads contending on one backend.
|
||||
- name: Sharded concurrency validation
|
||||
run: task cucumber:parallel SHARDS=10
|
||||
|
||||
- name: Container logs on failure
|
||||
if: failure()
|
||||
run: docker compose -f docker/embedded/compose/test_cicd.yml logs --tail 400
|
||||
|
||||
- name: Tear down
|
||||
if: always()
|
||||
run: docker compose -f docker/embedded/compose/test_cicd.yml down -v
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -73,13 +73,14 @@ jobs:
|
||||
echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
update-homebrew-and-scoop:
|
||||
environment: package-publish
|
||||
needs: get-release-info
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -27,29 +27,22 @@ jobs:
|
||||
name: Label conflicted PRs
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
contents: read # actions/checkout
|
||||
issues: write # get/create the repo-level conflict label
|
||||
pull-requests: write # pulls.get/list plus add/remove the label on PRs
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up stirling-bot token
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Apply conflict label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const conflictLabel = process.env.CONFLICT_LABEL;
|
||||
const owner = context.repo.owner;
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
cache-suffix: pre-commit
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Run pre-commit checks
|
||||
run: task pre-commit
|
||||
|
||||
@@ -17,6 +17,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
push-base:
|
||||
# Own environment: docker-publish is branch-locked to release/main,
|
||||
# which excludes the baseDockerImage/accessIssueFix branches this runs on.
|
||||
environment: docker-base-publish
|
||||
if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }}
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
@@ -32,9 +35,11 @@ jobs:
|
||||
|
||||
- name: Set version
|
||||
id: version
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
VERSION="${INPUT_VERSION}"
|
||||
elif [ "${{ github.ref_name }}" == "accessIssueFix" ]; then
|
||||
VERSION="1.0.3"
|
||||
else
|
||||
@@ -43,7 +48,7 @@ jobs:
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -20,10 +20,8 @@ on:
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- release
|
||||
- main
|
||||
- V2-master
|
||||
- testMain
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
@@ -42,6 +40,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
environment: docker-publish
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
@@ -53,29 +52,26 @@ jobs:
|
||||
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-push-docker-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
@@ -91,13 +87,13 @@ jobs:
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: github.ref == 'refs/heads/release'
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: github.ref == 'refs/heads/release'
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
@@ -133,8 +129,8 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/release' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/release' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
@@ -158,7 +154,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-latest.outputs.digest != ''
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-latest.outputs.digest != ''
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
@@ -182,8 +178,8 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/release' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/release' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
@@ -204,7 +200,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-fat.outputs.digest != ''
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-fat.outputs.digest != ''
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
@@ -226,8 +222,8 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
|
||||
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
@@ -248,7 +244,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-lite.outputs.digest != ''
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-lite.outputs.digest != ''
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
@@ -260,7 +256,7 @@ jobs:
|
||||
done
|
||||
|
||||
# Standalone unoserver image — versioned independently via
|
||||
# docker/unoserver/VERSION. master/V2-master: publish <version>+latest
|
||||
# docker/unoserver/VERSION. release: 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
|
||||
@@ -319,7 +315,7 @@ jobs:
|
||||
fi
|
||||
|
||||
case "$EFFECTIVE_REF" in
|
||||
refs/heads/master|refs/heads/V2-master)
|
||||
refs/heads/release)
|
||||
if [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
echo "force_unoserver_rebuild=true — building stable regardless"
|
||||
mode="stable"
|
||||
|
||||
@@ -13,12 +13,13 @@ permissions:
|
||||
|
||||
jobs:
|
||||
rollback:
|
||||
environment: docker-publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- release
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
@@ -23,33 +23,33 @@ permissions:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
# package-publish holds SWAGGERHUB_API_KEY. It requires reviewer approval and
|
||||
# is limited to main / release / v* tags, so every push to release waits on one.
|
||||
environment: package-publish
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-swagger-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
environment: bot-identity
|
||||
name: Sync docs manifest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
@@ -32,7 +33,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -33,10 +33,11 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync-files:
|
||||
environment: bot-identity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -65,7 +66,7 @@ jobs:
|
||||
uv sync --project engine --locked --group tools
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
|
||||
@@ -26,6 +26,10 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
use_shared_cache:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
@@ -57,13 +61,18 @@ permissions:
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
# Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted
|
||||
# signing environment - release-signing would block every PR run.
|
||||
environment:
|
||||
name: ci-signing
|
||||
deployment: false
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -103,6 +112,12 @@ jobs:
|
||||
echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
|
||||
|
||||
build:
|
||||
# Windows/GPG signing only runs on main (see the per-step gates below), so only
|
||||
# that path needs the reviewer-gated release-signing environment. Everything else
|
||||
# (PRs, merge queue, nightly) signs macOS only and uses ci-signing, which has no
|
||||
# approval or branch restriction.
|
||||
environment:
|
||||
name: ${{ (inputs.sign && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))) && 'release-signing' || 'ci-signing' }}
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -110,7 +125,6 @@ jobs:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
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 }}
|
||||
# Per-platform sign gate. macOS signs on any run with the cert available,
|
||||
@@ -120,7 +134,7 @@ jobs:
|
||||
SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -150,7 +164,7 @@ jobs:
|
||||
# only recompiles the app crate. Written on main; PRs and the merge queue
|
||||
# restore from it.
|
||||
- name: Cache Rust build
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: frontend/editor/src-tauri
|
||||
# Stable key shared across workflows so the nightly warmer.
|
||||
@@ -160,6 +174,24 @@ jobs:
|
||||
# Save the dependency cache even if a later step fails
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
if: inputs.use_shared_cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Restore cache Gradle
|
||||
if: inputs.use_shared_cache == false
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-tauri-build-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
@@ -179,17 +211,6 @@ jobs:
|
||||
java-version: "25"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -227,20 +248,26 @@ jobs:
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
env:
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certBytes = [Convert]::FromBase64String("$env:SM_CLIENT_CERT_FILE_B64")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=$env:SM_HOST" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=$env:SM_API_KEY" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=$env:SM_CLIENT_CERT_PASSWORD" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=$env:SM_KEYPAIR_ALIAS" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
@@ -258,38 +285,6 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
|
||||
env:
|
||||
@@ -697,7 +692,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -789,7 +784,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ jobs:
|
||||
# spring-security=true matrix entry if `task backend:build` and
|
||||
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
|
||||
test-build-docker-images:
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -53,7 +56,7 @@ jobs:
|
||||
cache-scope: stirling-pdf-fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -78,23 +81,20 @@ jobs:
|
||||
docker system prune -af || true
|
||||
echo "Disk space after cleanup:" && df -h
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Build application
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
name: UI test with TestDriverAI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master", "UITest", "testdriver"]
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push test image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS
|
||||
run: |
|
||||
cat > docker-compose.yml << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
stirling-pdf:
|
||||
container_name: stirling-pdf-test-${{ github.sha }}
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
|
||||
ports:
|
||||
- "1337:8080"
|
||||
volumes:
|
||||
- /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw
|
||||
- /stirling/test-${{ github.sha }}/config:/configs:rw
|
||||
- /stirling/test-${{ github.sha }}/logs:/logs:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "true"
|
||||
SECURITY_ENABLELOGIN: "false"
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: "Stirling-PDF Test"
|
||||
UI_HOMEDESCRIPTION: "Test Deployment"
|
||||
UI_APPNAMENAVBAR: "Test"
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "false"
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
|
||||
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
|
||||
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
|
||||
cd /stirling/test-${{ github.sha }}
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
EOF
|
||||
|
||||
files-changed:
|
||||
if: always()
|
||||
name: detect what files changed
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: changes
|
||||
with:
|
||||
filters: ".github/config/.files.yaml"
|
||||
|
||||
test:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: [deploy, files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Run TestDriver.ai
|
||||
uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3
|
||||
with:
|
||||
key: ${{secrets.TESTDRIVER_API_KEY}}
|
||||
prerun: |
|
||||
choco install go-task -y
|
||||
task frontend:build
|
||||
cd frontend
|
||||
npm install dashcam-chrome --save
|
||||
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
|
||||
Start-Sleep -Seconds 20
|
||||
prompt: |
|
||||
1. /run testing/testdriver/test.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FORCE_COLOR: "3"
|
||||
|
||||
cleanup:
|
||||
needs: [deploy, test]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup deployment
|
||||
if: always()
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
|
||||
cd /stirling/test-${{ github.sha }}
|
||||
docker-compose down
|
||||
cd /stirling
|
||||
rm -rf test-${{ github.sha }}
|
||||
EOF
|
||||
continue-on-error: true # Ensure cleanup runs even if previous steps fail
|
||||
@@ -26,6 +26,7 @@ watchedFolders/
|
||||
# also matches this frontend source component dir; keep the source tracked.
|
||||
!frontend/editor/src/proprietary/components/watchedFolders/
|
||||
clientWebUI/
|
||||
policy-webhook-spool/
|
||||
# Scratch dir used by local fixture-regeneration runs (see
|
||||
# app/proprietary/src/test/resources/db-migration-fixtures/README.md).
|
||||
# Holds downloaded JARs and disposable workdirs. Never committed.
|
||||
@@ -38,6 +39,7 @@ exampleYmlFiles/stirling/
|
||||
/testing/file_snapshots
|
||||
/testing/cucumber/junit/
|
||||
/testing/cucumber/report.html
|
||||
/testing/cucumber/.parallel/
|
||||
/testing/.failed_tests
|
||||
/.test-state/
|
||||
SwaggerDoc.json
|
||||
|
||||
@@ -27,3 +27,8 @@ app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase.
|
||||
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
|
||||
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
|
||||
.github/workflows/tauri-build.yml:generic-api-key:402
|
||||
|
||||
# Staging Supabase publishable key (public by design). Ignored here rather than with an
|
||||
# inline gitleaks:allow because a trailing comment in a .properties file is part of the
|
||||
# value, so the pragma would end up inside the key.
|
||||
app/saas/src/main/resources/application-staging.properties:generic-api-key:16
|
||||
|
||||
@@ -57,16 +57,57 @@ tasks:
|
||||
- cmd: ./gradlew clean bootRun -PbuildWithFrontend=true
|
||||
platforms: [linux, darwin]
|
||||
|
||||
# SaaS backend. dev:saas -> the PR's preview branch, staging:saas -> shared v3,
|
||||
# PROFILES=none -> production against your own SAAS_DB_*. Production has no named
|
||||
# task on purpose. Use `none`, not an empty value: Go template `default` treats ""
|
||||
# as absent and would resolve back to dev.
|
||||
|
||||
dev:saas:
|
||||
desc: "Start backend in SaaS flavor against Supabase"
|
||||
# `dotenv:` reads from the root Taskfile's directory (".") because this
|
||||
# subtaskfile is included with `dir: .`.
|
||||
desc: "Start SaaS backend against the current PR's Supabase preview branch"
|
||||
dotenv: ['app/.env.saas.local', 'app/.env.saas']
|
||||
vars:
|
||||
PROFILES: '{{.PROFILES | default "dev"}}'
|
||||
cmds:
|
||||
# Don't move this check into a `sh:` var: dotenv is visible in cmds but not
|
||||
# during var evaluation, so the test would always see an empty value.
|
||||
- cmd: |
|
||||
if [ "{{.PROFILES}}" = "dev" ] && [ -z "${SAAS_DEV_PROJECT_REF:-}" ]; then
|
||||
echo ">> SAAS_DEV_PROJECT_REF is not set."
|
||||
echo ">> Testing a SaaS PR? Put its ref, DB password and publishable key in app/.env.saas.local."
|
||||
echo ">> Wanted the shared v3 project? Use 'task backend:staging:saas' instead."
|
||||
exit 1
|
||||
fi
|
||||
- task: _run:saas
|
||||
vars:
|
||||
PORT: '{{.PORT}}'
|
||||
PROFILES: '{{.PROFILES}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
|
||||
staging:saas:
|
||||
desc: "Start SaaS backend against the shared v3 staging project"
|
||||
cmds:
|
||||
- task: _run:saas
|
||||
vars:
|
||||
PORT: '{{.PORT}}'
|
||||
PROFILES: staging
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
|
||||
_run:saas:
|
||||
internal: true
|
||||
dotenv: ['app/.env.saas.local', 'app/.env.saas']
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
|
||||
PROFILES: '{{.PROFILES | default "dev"}}'
|
||||
# Built here rather than inline in the cmds below: the Windows line is an
|
||||
# unquoted YAML scalar wrapping a cmd.exe string, so a nested {{if ne .X
|
||||
# "none"}} needs escaped quotes that reach the Go template as literal
|
||||
# backslashes and fail with `unexpected "\" in operand`.
|
||||
PROFILE_ARGS: '{{if ne .PROFILES "none"}}--spring.profiles.include={{.PROFILES}}{{end}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
@@ -77,9 +118,11 @@ tasks:
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
cmds:
|
||||
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
|
||||
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
|
||||
# against SAAS_DB_* (production).
|
||||
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args=\"{{.PROFILE_ARGS}}\"{{end}}"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILES}}--args='--spring.profiles.include={{.PROFILES}}'{{end}}
|
||||
- cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args='{{.PROFILE_ARGS}}'{{end}}
|
||||
platforms: [linux, darwin]
|
||||
|
||||
build:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
install:
|
||||
desc: "Sync the Python environment with the cucumber test dependencies"
|
||||
run: once
|
||||
# Deliberately no sources/status fingerprint: the engine venv is shared, so it can
|
||||
# already exist while synced to a different dependency group. uv no-ops when correct.
|
||||
cmds:
|
||||
- uv sync --project ../../engine --locked --group cucumber
|
||||
|
||||
run:
|
||||
desc: "Run the cucumber suite against a running server (BASE_URL, default localhost:8080)"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project ../../engine --locked --group cucumber python -m behave --no-capture -f plain {{.CLI_ARGS}}
|
||||
|
||||
nightly:
|
||||
desc: "Run the @nightly cucumber scenarios, excluded from the default run"
|
||||
summary: |
|
||||
Heavy LibreOffice/Calibre/Ghostscript conversions. behave.ini excludes @nightly,
|
||||
so this opts back in explicitly.
|
||||
|
||||
Pass extra behave flags via -- :
|
||||
task cucumber:nightly -- --tags=@convert
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project ../../engine --locked --group cucumber python -m behave --tags=@nightly --no-capture -f plain {{.CLI_ARGS}}
|
||||
|
||||
parallel:
|
||||
desc: "Run the cucumber suite as concurrent shards against one server (SHARDS, default 10)"
|
||||
summary: |
|
||||
Splits the feature files across SHARDS concurrent behave processes hitting a single
|
||||
backend, to shake out cross-request interference. Auth-coupled features are pinned
|
||||
to one shard because they change the admin password mid-scenario.
|
||||
|
||||
task cucumber:parallel
|
||||
task cucumber:parallel SHARDS=4
|
||||
BASE_URL=http://localhost:8081 task cucumber:parallel
|
||||
deps: [install]
|
||||
vars:
|
||||
SHARDS: '{{.SHARDS | default "10"}}'
|
||||
cmds:
|
||||
- bash run-parallel.sh {{.SHARDS}} {{if .CLI_ARGS}}-- {{.CLI_ARGS}}{{end}}
|
||||
@@ -5,6 +5,14 @@ version: '3'
|
||||
# mode flag) or use `--project editor/...` for tsc — so the editor lives
|
||||
# under frontend/editor/ without each task needing a cd.
|
||||
|
||||
vars:
|
||||
# Dev-only browser-tab label so concurrent worktrees are distinguishable. Only
|
||||
# the worktree folder basename (e.g. "wt1") is exposed — never the full path,
|
||||
# hostname, or user. Dropped from production builds.
|
||||
DEV_LABEL:
|
||||
sh: >-
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
|
||||
|
||||
tasks:
|
||||
install:
|
||||
desc: "Install dependencies"
|
||||
@@ -80,16 +88,52 @@ tasks:
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
# Dev-only browser-tab label so concurrent worktrees are distinguishable.
|
||||
# Only the worktree folder basename (e.g. "wt1") is exposed — never the
|
||||
# full path, hostname, or user. Consumed at dev-serve time by vite.config
|
||||
# and dropped from production builds.
|
||||
STIRLING_DEV_LABEL:
|
||||
sh: >-
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
|
||||
STIRLING_DEV_LABEL: '{{.DEV_LABEL}}'
|
||||
cmds:
|
||||
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
# Separate from dev:_run rather than a flag on it: Task sets an `env:` key even
|
||||
# when its value resolves to empty, and Vite treats an empty process.env VITE_* as
|
||||
# authoritative over the committed editor/.env, so folding these in blanks Supabase
|
||||
# config for the core, proprietary and desktop dev servers.
|
||||
dev:_run:saas:
|
||||
internal: true
|
||||
ignore_error: true
|
||||
# The backend's own env files, so both halves target one project. Paths are
|
||||
# relative to this taskfile's dir, `frontend`.
|
||||
dotenv: ['../app/.env.saas.local', '../app/.env.saas']
|
||||
vars:
|
||||
PORT: '{{.PORT | default "5173"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV | default "dev"}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
STIRLING_DEV_LABEL: '{{.DEV_LABEL}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
# A real process.env VITE_* beats a committed .env in Vite (loadEnv applies
|
||||
# process.env last), which is what lets this override editor/.env.
|
||||
#
|
||||
# These must stay `sh:`, not Go templates: dotenv values are visible to Task's
|
||||
# embedded shell but not to templates, where {{.SAAS_DEV_PROJECT_REF}} is
|
||||
# always empty.
|
||||
VITE_SUPABASE_URL:
|
||||
sh: |
|
||||
case "${SAAS_ENV:-dev}" in
|
||||
staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;;
|
||||
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;;
|
||||
esac
|
||||
echo "https://${ref}.supabase.co"
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
|
||||
sh: |
|
||||
case "${SAAS_ENV:-dev}" in
|
||||
staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
|
||||
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
|
||||
esac
|
||||
cmds:
|
||||
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
|
||||
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
dev:
|
||||
desc: "Start frontend dev server"
|
||||
cmds:
|
||||
@@ -111,13 +155,23 @@ tasks:
|
||||
vars: { MODE: proprietary, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
dev:saas:
|
||||
desc: "Start frontend dev server in SaaS mode"
|
||||
desc: "Start frontend dev server in SaaS mode (SAAS_ENV=dev|staging|prod)"
|
||||
deps:
|
||||
- task: prepare
|
||||
vars: { MODE: saas }
|
||||
vars:
|
||||
SAAS_ENV: '{{.SAAS_ENV | default "dev"}}'
|
||||
# prod routes to the plain runner, which sets no VITE_SUPABASE_* and so leaves
|
||||
# the committed editor/.env alone.
|
||||
RUNNER: '{{if eq .SAAS_ENV "prod"}}dev:_run{{else}}dev:_run:saas{{end}}'
|
||||
cmds:
|
||||
- task: dev:_run
|
||||
vars: { MODE: saas, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
- task: '{{.RUNNER}}'
|
||||
vars:
|
||||
MODE: saas
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
OPEN: '{{.OPEN}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
|
||||
dev:desktop:
|
||||
desc: "Start frontend dev server in desktop mode"
|
||||
@@ -210,15 +264,26 @@ tasks:
|
||||
# task frontend:storybook:test -- Button
|
||||
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
|
||||
|
||||
storybook:a11y:
|
||||
desc: "a11y regression gate over every story, light and dark: fail only on NEW axe violations"
|
||||
storybook:a11y:light:
|
||||
desc: "a11y gate over every story in light mode"
|
||||
deps: [prepare, storybook:browser]
|
||||
cmds:
|
||||
- node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-scan.mjs {{.CLI_ARGS}}
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
|
||||
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
|
||||
|
||||
storybook:a11y:dark:
|
||||
desc: "a11y gate over every story in dark mode"
|
||||
deps: [prepare, storybook:browser]
|
||||
cmds:
|
||||
- SCAN_THEME=dark node .storybook/a11y-scan.mjs {{.CLI_ARGS}}
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
|
||||
|
||||
storybook:a11y:
|
||||
desc: "a11y gate over every story, light and dark"
|
||||
cmds:
|
||||
- task: storybook:a11y:light
|
||||
- task: storybook:a11y:dark
|
||||
|
||||
storybook:a11y:changed:
|
||||
desc: "a11y gate over the stories this branch affects (default base origin/main)"
|
||||
summary: |
|
||||
@@ -233,7 +298,6 @@ tasks:
|
||||
|
||||
Pass a base ref through CLI_ARGS, e.g.
|
||||
task frontend:storybook:a11y:changed -- origin/release
|
||||
deps: [prepare, storybook:browser]
|
||||
vars:
|
||||
BASE: '{{.CLI_ARGS | default "origin/main"}}'
|
||||
CHANGED:
|
||||
@@ -244,10 +308,10 @@ tasks:
|
||||
echo "a11y: no story files affected vs {{.BASE}} — nothing to check"
|
||||
exit 0
|
||||
fi
|
||||
node .storybook/a11y-scan.mjs {{.CHANGED}}
|
||||
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
|
||||
SCAN_THEME=dark node .storybook/a11y-scan.mjs {{.CHANGED}}
|
||||
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
|
||||
rc=0
|
||||
task frontend:storybook:a11y:light -- {{.CHANGED}} || rc=1
|
||||
task frontend:storybook:a11y:dark -- {{.CHANGED}} || rc=1
|
||||
exit $rc
|
||||
|
||||
storybook:a11y:record:
|
||||
desc: "Re-record both a11y baselines (run after intentionally fixing/adding violations)"
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
Thank you for your interest in contributing to Stirling-PDF! There are many ways to contribute other than writing code. For example, reporting bugs, creating suggestions, and adding or modifying translations.
|
||||
|
||||
## License
|
||||
|
||||
By contributing to this project, you agree that your contributions will be licensed under the project [license](LICENSE), which follows an open-core model.
|
||||
The codebase is a mix of MIT and source-available code, so your contribution is licensed according to the directory it is committed to.
|
||||
|
||||
PRs are welcome in any directory by any user, just be aware of which license applies to the code you change.
|
||||
|
||||
## Issue Guidelines
|
||||
|
||||
Issues can be used to report bugs, request features, or ask questions. If you have a question, you could also ask us in our [Discord](https://discord.gg/FJUSXUSYec).
|
||||
@@ -63,7 +70,3 @@ For technical guides, setup instructions, and development resources:
|
||||
For configuration and usage guides, see:
|
||||
- [Database Guide](DATABASE.md) - Database setup and configuration
|
||||
- [OCR Guide](HowToUseOCR.md) - OCR setup and configuration
|
||||
|
||||
## License
|
||||
|
||||
By contributing to this project, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
|
||||
@@ -25,6 +25,9 @@ includes:
|
||||
e2e:
|
||||
taskfile: .taskfiles/e2e.yml
|
||||
dir: .
|
||||
cucumber:
|
||||
taskfile: .taskfiles/cucumber.yml
|
||||
dir: testing/cucumber
|
||||
pre-commit:
|
||||
taskfile: .taskfiles/pre-commit.yml
|
||||
dir: .
|
||||
@@ -96,11 +99,22 @@ tasks:
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
# Set SAAS_DEV_PROJECT_REF in app/.env.saas.local to pick the PR.
|
||||
dev:saas:
|
||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||
desc: "Start SaaS backend + frontend + engine against the current PR's preview branch"
|
||||
cmds:
|
||||
- task: dev:_all
|
||||
vars: { FRONTEND: saas, BACKEND: saas }
|
||||
vars: { FRONTEND: saas, BACKEND: saas, SAAS_ENV: dev }
|
||||
|
||||
staging:saas:
|
||||
desc: "Start SaaS backend + frontend + engine against the shared v3 staging project"
|
||||
cmds:
|
||||
- task: dev:_all
|
||||
vars:
|
||||
FRONTEND: saas
|
||||
BACKEND: saas
|
||||
BACKEND_TASK: backend:staging:saas
|
||||
SAAS_ENV: staging
|
||||
|
||||
dev:all:
|
||||
desc: "Start backend + frontend + engine concurrently on free ports"
|
||||
@@ -112,6 +126,9 @@ tasks:
|
||||
vars:
|
||||
FRONTEND: '{{.FRONTEND | default "proprietary"}}'
|
||||
BACKEND: '{{.BACKEND | default "proprietary"}}'
|
||||
BACKEND_TASK: '{{.BACKEND_TASK | default (printf "backend:dev:%s" .BACKEND)}}'
|
||||
# Only meaningful to the saas frontend; every other flavor ignores it.
|
||||
SAAS_ENV: '{{.SAAS_ENV | default ""}}'
|
||||
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}}'
|
||||
@@ -121,7 +138,7 @@ tasks:
|
||||
- task: engine:dev
|
||||
vars:
|
||||
PORT: '{{.ENGINE_PORT}}'
|
||||
- task: 'backend:dev:{{.BACKEND}}'
|
||||
- task: '{{.BACKEND_TASK}}'
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
||||
@@ -131,6 +148,7 @@ tasks:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
|
||||
@@ -4,6 +4,11 @@ This guide explains how to set up Windows code signing for Stirling-PDF desktop
|
||||
|
||||
## Overview
|
||||
|
||||
Releases are signed with **DigiCert KeyLocker**, a cloud HSM: the private key never
|
||||
leaves DigiCert, and the runner signs through a PKCS#11 provider. The older approach
|
||||
of uploading a base64 `.pfx` to a repository secret has been removed from the
|
||||
workflows - the sections below describe KeyLocker, which is what actually runs.
|
||||
|
||||
Windows code signing is essential for:
|
||||
- Preventing Windows SmartScreen warnings
|
||||
- Building trust with users
|
||||
@@ -49,29 +54,19 @@ openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certifica
|
||||
|
||||
### Required Secrets
|
||||
|
||||
Navigate to your GitHub repository → Settings → Secrets and variables → Actions
|
||||
Navigate to your GitHub repository → Settings → Environments → `release-signing`.
|
||||
|
||||
Add the following secrets:
|
||||
These live in the `release-signing` environment, not at repository scope. That
|
||||
environment requires reviewer approval and is limited to `main`, `release`,
|
||||
`hotfix/*` and `v*` tags. All five come from the DigiCert ONE console.
|
||||
|
||||
#### 1. `WINDOWS_CERTIFICATE`
|
||||
- **Description**: Base64-encoded .pfx certificate file
|
||||
- **How to create**:
|
||||
|
||||
**On macOS/Linux:**
|
||||
```bash
|
||||
base64 -i certificate.pfx | pbcopy # Copies to clipboard
|
||||
```
|
||||
|
||||
**On Windows (PowerShell):**
|
||||
```powershell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
|
||||
```
|
||||
|
||||
Paste the entire base64 string into the GitHub secret.
|
||||
|
||||
#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
|
||||
- **Description**: Password for the .pfx certificate
|
||||
- **Value**: The password you set when creating/exporting the .pfx file
|
||||
| Secret | Description |
|
||||
| --- | --- |
|
||||
| `SM_API_KEY` | KeyLocker API key. Also acts as the on/off switch: signing steps are gated on it being non-empty. |
|
||||
| `SM_CLIENT_CERT_FILE_B64` | Base64-encoded PKCS#12 client authentication certificate. |
|
||||
| `SM_CLIENT_CERT_PASSWORD` | Password for that client certificate. |
|
||||
| `SM_KEYPAIR_ALIAS` | Alias of the signing keypair to use. |
|
||||
| `SM_HOST` | DigiCert ONE host, e.g. `https://clientauth.one.digicert.com`. |
|
||||
|
||||
### Optional Secrets for Tauri Updater
|
||||
|
||||
@@ -110,23 +105,23 @@ The Windows signing configuration is already set up:
|
||||
|
||||
### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
|
||||
|
||||
The workflow includes three Windows signing steps:
|
||||
The workflow includes four Windows signing steps, all gated on `SM_API_KEY` being
|
||||
set and the ref being the release branch:
|
||||
|
||||
1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
|
||||
2. **Build Tauri App**: Builds and signs the application using the imported certificate
|
||||
3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
|
||||
1. **Setup DigiCert KeyLocker**: Installs the DigiCert signing tools via `digicert/ssm-code-signing`
|
||||
2. **Setup DigiCert KeyLocker Certificate**: Writes the client cert and exports the PKCS#11 config
|
||||
3. **Configure Windows code signing / Build Tauri app**: Signs through the PKCS#11 provider
|
||||
4. **Verify Windows Code Signature**: Validates that the .exe and .msi are properly signed
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
### 1. Local Testing (Windows Only)
|
||||
|
||||
Before pushing to GitHub, test locally:
|
||||
KeyLocker is CI-only. To check signing locally, install your own certificate into
|
||||
the Windows store and point Tauri at it; the build no longer reads any certificate
|
||||
from an environment variable.
|
||||
|
||||
```powershell
|
||||
# Set environment variables
|
||||
$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
|
||||
$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
|
||||
|
||||
# Build the application
|
||||
cd frontend
|
||||
npm run tauri build
|
||||
@@ -191,9 +186,10 @@ Look for:
|
||||
- Consider EV certificate for immediate reputation
|
||||
|
||||
### Certificate Not Found During Build
|
||||
- Verify `WINDOWS_CERTIFICATE` secret is set
|
||||
- Check base64 encoding is correct (no extra whitespace)
|
||||
- Ensure password is correct
|
||||
- Verify `SM_API_KEY` is present in the `release-signing` environment. If it is empty
|
||||
the signing steps skip silently and the build succeeds unsigned.
|
||||
- Check `SM_CLIENT_CERT_FILE_B64` base64 encoding is correct (no extra whitespace)
|
||||
- Ensure `SM_CLIENT_CERT_PASSWORD` and `SM_KEYPAIR_ALIAS` match the DigiCert keypair
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
@@ -220,11 +216,10 @@ Look for:
|
||||
## Certificate Lifecycle
|
||||
|
||||
### Before Expiration
|
||||
1. Obtain new certificate from CA (typically annual renewal)
|
||||
2. Convert to .pfx format if needed
|
||||
3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
|
||||
4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
|
||||
5. Test build to verify new certificate works
|
||||
1. Renew the certificate in the DigiCert ONE console (typically annual)
|
||||
2. If the keypair alias changed, update `SM_KEYPAIR_ALIAS` in the `release-signing` environment
|
||||
3. If the client authentication certificate was reissued, update `SM_CLIENT_CERT_FILE_B64` and `SM_CLIENT_CERT_PASSWORD`
|
||||
4. Test build to verify the new certificate works
|
||||
|
||||
### Expired Certificates
|
||||
- Signed binaries remain valid (timestamp proves signing time)
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
###############################################################################
|
||||
# Stirling-PDF SaaS environment defaults.
|
||||
# Stirling-PDF SaaS environment defaults. Committed, non-secret. Real values for secrets go in
|
||||
# .env.saas.local, which is loaded first and wins. Do not commit that file.
|
||||
#
|
||||
# This file is committed and provides non-secret defaults loaded by
|
||||
# `task backend:dev:saas`. Put real values for secrets (passwords, project
|
||||
# refs, edge function secrets) in `.env.saas.local` - any variable set there
|
||||
# takes precedence over what's defined here.
|
||||
# Three environments, each deriving its Supabase URLs, JWT issuer and JWKS from one project ref:
|
||||
#
|
||||
# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in.
|
||||
###############################################################################
|
||||
# prod PROFILES=none SAAS_DB_* the live project
|
||||
# staging PROFILES=staging SAAS_STAGING_* pinned to v3, always there
|
||||
# dev PROFILES=dev SAAS_DEV_* follows a SaaS PR's preview branch
|
||||
#
|
||||
# dev is the default for `task backend:dev:saas`. Use staging for somewhere stable; use dev when
|
||||
# testing an open SaaS PR, since its preview branch is the only place those migrations are applied.
|
||||
|
||||
# ---------- Supabase project ----------
|
||||
# ---------- Supabase project (prod / no-profile) ----------
|
||||
# Project reference (the subdomain part of <ref>.supabase.co). Required.
|
||||
# Set in .env.saas.local.
|
||||
SAAS_DB_PROJECT_REF=
|
||||
@@ -17,18 +18,35 @@ SAAS_DB_PROJECT_REF=
|
||||
# Edge function secret used by billing/license rollup calls. Set in .env.saas.local.
|
||||
SUPABASE_EDGE_FUNCTION_SECRET=
|
||||
|
||||
# ---------- Database (saas profile) ----------
|
||||
# Direct JDBC URL to the Supabase Postgres. Required when running the plain
|
||||
# `saas` profile (i.e. without `--spring.profiles.include=dev`).
|
||||
# ---------- Database (no profile) ----------
|
||||
# Direct JDBC URL to the Supabase Postgres. Required when running without
|
||||
# `--spring.profiles.include=...`.
|
||||
# Example: jdbc:postgresql://db.<project-ref>.supabase.co:5432/postgres
|
||||
SAAS_DB_URL=
|
||||
SAAS_DB_USERNAME=postgres
|
||||
SAAS_DB_PASSWORD=
|
||||
|
||||
# ---------- Database (dev profile overrides) ----------
|
||||
# Used when `--spring.profiles.include=dev` is active. The dev profile
|
||||
# defaults the URL/username to the shared dev Supabase project, but the
|
||||
# password must still be provided in .env.saas.local.
|
||||
SAAS_DEV_DB_URL=
|
||||
# ---------- staging profile ----------
|
||||
# The shared long-lived v3 project. application-staging.properties defaults the ref,
|
||||
# URL, database host and meter endpoint, so staging needs only the password, in
|
||||
# .env.saas.local. Set SAAS_STAGING_PROJECT_REF to repoint it; everything derives.
|
||||
#
|
||||
# The ref and publishable key are duplicated here because the task derives the
|
||||
# frontend's VITE_SUPABASE_* from them and a shell cannot read a Spring default.
|
||||
# Neither is secret: the ref is a public subdomain, the key ships in the bundle.
|
||||
SAAS_STAGING_PROJECT_REF=qacaivhsjtftfwtgjvva
|
||||
SAAS_STAGING_PUBLISHABLE_KEY=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
|
||||
SAAS_STAGING_DB_USERNAME=postgres
|
||||
SAAS_STAGING_DB_PASSWORD=
|
||||
|
||||
# ---------- dev profile ----------
|
||||
# The SaaS PR's Supabase preview branch. Take the ref from that PR's "Supabase
|
||||
# Preview" check; the profile derives URL, JWT issuer, JWKS, meter endpoint and
|
||||
# database host from it, so this one value follows a different PR.
|
||||
#
|
||||
# A preview branch has its own password and keys; the parent project's will not
|
||||
# authenticate. Both go in .env.saas.local, along with the ref.
|
||||
SAAS_DEV_PROJECT_REF=
|
||||
SAAS_DEV_PUBLISHABLE_KEY=
|
||||
SAAS_DEV_DB_USERNAME=postgres
|
||||
SAAS_DEV_DB_PASSWORD=
|
||||
|
||||
@@ -2,7 +2,9 @@ package stirling.software.common.aop;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
@@ -273,6 +275,7 @@ public class AutoJobAspect {
|
||||
|
||||
// Store the fileId for later reference
|
||||
pdfFile.setFileId(fileId);
|
||||
recordPendingInputFile(fileId);
|
||||
|
||||
// Replace the original MultipartFile with our persistent copy
|
||||
MultipartFile persistentFile = fileStorage.retrieveFile(fileId);
|
||||
@@ -290,6 +293,29 @@ public class AutoJobAspect {
|
||||
return originalArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an input copy for attribution to the job. The job id does not exist yet at this point,
|
||||
* so {@link JobExecutorService} drains this list once it mints one.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void recordPendingInputFile(String fileId) {
|
||||
try {
|
||||
Object existing = request.getAttribute(JobExecutorService.PENDING_INPUT_FILE_IDS_ATTR);
|
||||
List<String> ids;
|
||||
if (existing instanceof List<?> list) {
|
||||
ids = (List<String>) list;
|
||||
} else {
|
||||
ids = new ArrayList<>();
|
||||
request.setAttribute(JobExecutorService.PENDING_INPUT_FILE_IDS_ATTR, ids);
|
||||
}
|
||||
ids.add(fileId);
|
||||
} catch (RuntimeException ex) {
|
||||
// Without a bound request the copy cannot be attributed; the periodic sweep is the
|
||||
// only backstop, so make the miss visible rather than silently leaking the file.
|
||||
log.warn("Could not record input copy {} for cleanup: {}", fileId, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String getJobIdFromContext() {
|
||||
try {
|
||||
return (String) request.getAttribute("jobId");
|
||||
|
||||
@@ -1316,7 +1316,7 @@ public class ApplicationProperties {
|
||||
public static class Ui {
|
||||
private String appNameNavbar;
|
||||
private List<String> languages;
|
||||
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
|
||||
private String logoStyle = "modern"; // Options: "modern" (default) or "classic"
|
||||
private boolean defaultHideUnavailableTools = false;
|
||||
private boolean defaultHideUnavailableConversions = false;
|
||||
private HideDisabledTools hideDisabledTools = new HideDisabledTools();
|
||||
@@ -1327,10 +1327,10 @@ public class ApplicationProperties {
|
||||
|
||||
public String getLogoStyle() {
|
||||
// Validate and return either "modern" or "classic"
|
||||
if ("modern".equalsIgnoreCase(logoStyle)) {
|
||||
return "modern";
|
||||
if ("classic".equalsIgnoreCase(logoStyle)) {
|
||||
return "classic";
|
||||
}
|
||||
return "classic"; // default
|
||||
return "modern"; // default
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -52,6 +52,13 @@ public class JobResult {
|
||||
/** Key/value metadata that survives the write-through into the shared job store. */
|
||||
private final Map<String, String> metadata = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* File ids of the persistent input copies made for this job. An async submit copies the upload
|
||||
* into FileStorage so the job can still read it after the request returns; without tracking
|
||||
* them here nothing would ever delete those copies.
|
||||
*/
|
||||
@JsonIgnore private final List<String> inputFileIds = new CopyOnWriteArrayList<>();
|
||||
|
||||
/**
|
||||
* Create a new JobResult with the given job ID
|
||||
*
|
||||
@@ -167,6 +174,22 @@ public class JobResult {
|
||||
return Collections.unmodifiableList(notes);
|
||||
}
|
||||
|
||||
/** Record a persistent input copy so job cleanup deletes it alongside the results. */
|
||||
public void addInputFileId(String fileId) {
|
||||
if (fileId != null && !fileId.isBlank() && !inputFileIds.contains(fileId)) {
|
||||
this.inputFileIds.add(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File ids of this job's persistent input copies.
|
||||
*
|
||||
* @return An unmodifiable view of the input file ids
|
||||
*/
|
||||
public List<String> getInputFileIds() {
|
||||
return Collections.unmodifiableList(inputFileIds);
|
||||
}
|
||||
|
||||
/** Attach a metadata value, e.g. a policy id so cluster peers can identify a policy run. */
|
||||
public void putMetadata(String key, String value) {
|
||||
if (key != null && value != null) {
|
||||
|
||||
@@ -729,4 +729,32 @@ public class CustomPDFDocumentFactory {
|
||||
p.toFile().deleteOnExit();
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A custom RandomAccessRead implementation that deletes the file when closed */
|
||||
private static class DeletingRandomAccessFile extends RandomAccessReadBufferedFile {
|
||||
private final Path tempFilePath;
|
||||
|
||||
public DeletingRandomAccessFile(File file) throws IOException {
|
||||
super(file);
|
||||
this.tempFilePath = file.toPath();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
try {
|
||||
super.close();
|
||||
} finally {
|
||||
try {
|
||||
boolean deleted = Files.deleteIfExists(tempFilePath);
|
||||
if (deleted) {
|
||||
log.info("Successfully deleted temp file: {}", tempFilePath);
|
||||
} else {
|
||||
log.warn("Failed to delete temp file (may not exist): {}", tempFilePath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting temp file: {}", tempFilePath, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,21 @@ public class FileStorage {
|
||||
return fileStore.delete(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a stored file without the per-file ownership check.
|
||||
*
|
||||
* <p>Job cleanup authorises at the job level and then deletes that job's own files, so the
|
||||
* deleter is legitimately not their owner - an admin sweeping every user's jobs, or the
|
||||
* unauthenticated scheduled task. Routing those through {@link #deleteFile(String)} makes the
|
||||
* ownership check throw and silently orphans the files on disk.
|
||||
*
|
||||
* <p>Only ever pass file ids read back off a job that the caller has already been authorised
|
||||
* for; never a caller-supplied id.
|
||||
*/
|
||||
public boolean deleteFileAsSystem(String fileId) {
|
||||
return fileStore.delete(fileId);
|
||||
}
|
||||
|
||||
public boolean fileExists(String fileId) {
|
||||
enforceOwnership(fileId);
|
||||
return fileStore.exists(fileId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -33,6 +34,14 @@ import stirling.software.common.util.RegexPatternUtils;
|
||||
@Slf4j
|
||||
public class JobExecutorService {
|
||||
|
||||
/**
|
||||
* Request attribute holding the FileStorage ids of persistent input copies made for the job
|
||||
* about to be created. Populated before the job id exists (the aspect copies the upload while
|
||||
* processing arguments), drained onto the JobResult as soon as the task is created so cleanup
|
||||
* can delete them.
|
||||
*/
|
||||
public static final String PENDING_INPUT_FILE_IDS_ATTR = "autoJobPendingInputFileIds";
|
||||
|
||||
private final TaskManager taskManager;
|
||||
private final FileStorage fileStorage;
|
||||
private final HttpServletRequest request;
|
||||
@@ -133,6 +142,7 @@ public class JobExecutorService {
|
||||
resourceWeight);
|
||||
|
||||
taskManager.createTask(jobId);
|
||||
registerPendingInputFiles(jobId);
|
||||
|
||||
final String capturedJobIdForQueue = jobId;
|
||||
Supplier<Object> wrappedWork =
|
||||
@@ -163,6 +173,7 @@ public class JobExecutorService {
|
||||
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
|
||||
} else if (async) {
|
||||
taskManager.createTask(jobId);
|
||||
registerPendingInputFiles(jobId);
|
||||
|
||||
final String capturedJobId = jobId;
|
||||
|
||||
@@ -484,4 +495,30 @@ public class JobExecutorService {
|
||||
}
|
||||
return baseJobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the input copies made while processing arguments to the freshly created job, so job
|
||||
* cleanup deletes them. Drains the attribute so a retry cannot attribute the same ids twice.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void registerPendingInputFiles(String jobId) {
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
Object pending;
|
||||
try {
|
||||
pending = request.getAttribute(PENDING_INPUT_FILE_IDS_ATTR);
|
||||
request.removeAttribute(PENDING_INPUT_FILE_IDS_ATTR);
|
||||
} catch (RuntimeException ex) {
|
||||
// No request bound to this thread (e.g. an internally dispatched job).
|
||||
log.debug("Could not read pending input file ids: {}", ex.getMessage());
|
||||
return;
|
||||
}
|
||||
if (!(pending instanceof List<?> ids)) {
|
||||
return;
|
||||
}
|
||||
for (String fileId : (List<String>) ids) {
|
||||
taskManager.registerInputFile(jobId, fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
@@ -234,6 +235,24 @@ public class TaskManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a persistent input copy against a job so cleanup deletes it with the results.
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @param fileId The FileStorage id of the input copy
|
||||
* @return true if the job exists and the id was recorded
|
||||
*/
|
||||
public boolean registerInputFile(String jobId, String fileId) {
|
||||
JobResult jobResult = jobResults.get(jobId);
|
||||
if (jobResult == null) {
|
||||
log.warn("Attempted to register an input file against non-existent job ID: {}", jobId);
|
||||
return false;
|
||||
}
|
||||
jobResult.addInputFileId(fileId);
|
||||
log.debug("Registered input file {} for job {}", fileId, jobId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Attach metadata to a job and write it through to the shared store for cluster peers. */
|
||||
public boolean putMetadata(String jobId, String key, String value) {
|
||||
JobResult jobResult = jobResults.get(jobId);
|
||||
@@ -329,25 +348,59 @@ public class TaskManager {
|
||||
return jobResults.computeIfAbsent(jobId, JobResult::createNew);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a cleanup pass removed. Returned by the on-demand cleanup so callers can assert on it.
|
||||
*/
|
||||
public record CleanupSummary(int jobsRemoved, int filesDeleted, int jobsRetained) {}
|
||||
|
||||
/** Clean up old completed job results. No-op in cluster mode; the backplane TTL owns expiry. */
|
||||
public void cleanupOldJobs() {
|
||||
public CleanupSummary cleanupOldJobs() {
|
||||
if (clusterBackplane != null && !clusterBackplane.shouldRunLocalCleanup()) {
|
||||
return;
|
||||
return new CleanupSummary(0, 0, jobResults.size());
|
||||
}
|
||||
return cleanupJobs(false, jobId -> true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-expire this node's finished jobs now, ignoring the age threshold. Jobs still running
|
||||
* are left alone - deleting their files mid-flight would break them - and are reported as
|
||||
* retained.
|
||||
*
|
||||
* <p>Unlike {@link #cleanupOldJobs()} this always runs locally: it is an explicit request to
|
||||
* release this node's storage, not the scheduled sweep the backplane TTL owns.
|
||||
*
|
||||
* @param jobIdFilter Only jobs whose id passes this predicate are considered, so a caller can
|
||||
* restrict the sweep to jobs the requester is allowed to touch
|
||||
* @return What was removed
|
||||
*/
|
||||
public CleanupSummary cleanupFinishedJobsNow(Predicate<String> jobIdFilter) {
|
||||
return cleanupJobs(true, jobIdFilter);
|
||||
}
|
||||
|
||||
private CleanupSummary cleanupJobs(boolean force, Predicate<String> filter) {
|
||||
LocalDateTime expiryThreshold =
|
||||
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
|
||||
LocalDateTime pendingExpiryThreshold =
|
||||
LocalDateTime.now().minus(pendingJobExpiryMinutes, ChronoUnit.MINUTES);
|
||||
int removedCount = 0;
|
||||
int filesDeleted = 0;
|
||||
int retainedCount = 0;
|
||||
|
||||
try {
|
||||
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
|
||||
JobResult result = entry.getValue();
|
||||
|
||||
if (!filter.test(entry.getKey())) {
|
||||
retainedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean expiredCompletedJob =
|
||||
result.isComplete()
|
||||
&& result.getCompletedAt() != null
|
||||
&& result.getCompletedAt().isBefore(expiryThreshold);
|
||||
&& (force
|
||||
|| (result.getCompletedAt() != null
|
||||
&& result.getCompletedAt()
|
||||
.isBefore(expiryThreshold)));
|
||||
boolean abandonedPendingJob =
|
||||
!result.isComplete()
|
||||
&& result.getCreatedAt() != null
|
||||
@@ -360,7 +413,7 @@ public class TaskManager {
|
||||
|
||||
// Clean up file results
|
||||
if (expiredCompletedJob) {
|
||||
cleanupJobFiles(result, entry.getKey());
|
||||
filesDeleted += cleanupJobFiles(result, entry.getKey());
|
||||
}
|
||||
|
||||
// Remove the job result
|
||||
@@ -369,15 +422,22 @@ public class TaskManager {
|
||||
jobStore.delete(entry.getKey());
|
||||
}
|
||||
removedCount++;
|
||||
} else {
|
||||
retainedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
log.info("Cleaned up {} expired job results", removedCount);
|
||||
log.info(
|
||||
"Cleaned up {} {} job results ({} files deleted)",
|
||||
removedCount,
|
||||
force ? "finished" : "expired",
|
||||
filesDeleted);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error during job cleanup: {}", e.getMessage(), e);
|
||||
}
|
||||
return new CleanupSummary(removedCount, filesDeleted, retainedCount);
|
||||
}
|
||||
|
||||
/** Mirror the in-memory {@code JobResult} into the cluster-visible {@link JobStore}. */
|
||||
@@ -525,22 +585,43 @@ public class TaskManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up files associated with a job result */
|
||||
private void cleanupJobFiles(JobResult result, String jobId) {
|
||||
/**
|
||||
* Clean up files associated with a job result: both the results and the persistent input copy
|
||||
* an async submit made of the upload.
|
||||
*
|
||||
* @return The number of files actually deleted
|
||||
*/
|
||||
private int cleanupJobFiles(JobResult result, String jobId) {
|
||||
int deleted = 0;
|
||||
// Clean up all result files
|
||||
if (result.hasFiles()) {
|
||||
for (ResultFile resultFile : result.getAllResultFiles()) {
|
||||
try {
|
||||
fileStorage.deleteFile(resultFile.getFileId());
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to delete file {} for job {}: {}",
|
||||
resultFile.getFileId(),
|
||||
jobId,
|
||||
e.getMessage());
|
||||
if (deleteJobFile(resultFile.getFileId(), jobId)) {
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String inputFileId : result.getInputFileIds()) {
|
||||
if (deleteJobFile(inputFileId, jobId)) {
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes as the system, not as the caller: an admin sweeping another user's jobs, or the
|
||||
* scheduled task running with no security context, is not the file's owner, and the
|
||||
* ownership-checked delete would throw and leave the file orphaned on disk. The job itself is
|
||||
* already authorised by the time we get here, and these ids come off that job, not the request.
|
||||
*/
|
||||
private boolean deleteJobFile(String fileId, String jobId) {
|
||||
try {
|
||||
return fileStorage.deleteFileAsSystem(fileId);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to delete file {} for job {}: {}", fileId, jobId, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the ResultFile metadata for a given file ID by searching through all job results */
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -54,6 +55,10 @@ public class GeneralUtils {
|
||||
|
||||
private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
|
||||
private final String PYTHON_SCRIPTS_DIR = "python";
|
||||
|
||||
// Extracted once per run. Rewriting a script while another request is exec-ing it
|
||||
// races wherever rename is not atomic, such as 9p or NFS bind mounts.
|
||||
private final Map<String, Path> EXTRACTED_SCRIPTS = new ConcurrentHashMap<>();
|
||||
private final RegexPatternUtils patternCache = RegexPatternUtils.getInstance();
|
||||
// Valid size units used for convertSizeToBytes validation and parsing
|
||||
private final Set<String> VALID_SIZE_UNITS = Set.of("B", "KB", "MB", "GB", "TB");
|
||||
@@ -1025,17 +1030,30 @@ public class GeneralUtils {
|
||||
}
|
||||
|
||||
Path scriptsDir = Path.of(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR);
|
||||
Files.createDirectories(scriptsDir);
|
||||
|
||||
Path target = scriptsDir.resolve(scriptName);
|
||||
ClassPathResource res =
|
||||
new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName);
|
||||
if (!res.exists()) {
|
||||
log.error("Resource not found: {}", res.getPath());
|
||||
throw new IOException("Resource not found: " + res.getPath());
|
||||
|
||||
Path cached = EXTRACTED_SCRIPTS.get(scriptName);
|
||||
if (cached != null && Files.isRegularFile(cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
synchronized (EXTRACTED_SCRIPTS) {
|
||||
cached = EXTRACTED_SCRIPTS.get(scriptName);
|
||||
if (cached != null && Files.isRegularFile(cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
Files.createDirectories(scriptsDir);
|
||||
ClassPathResource res =
|
||||
new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName);
|
||||
if (!res.exists()) {
|
||||
log.error("Resource not found: {}", res.getPath());
|
||||
throw new IOException("Resource not found: " + res.getPath());
|
||||
}
|
||||
copyResourceToFile(res, target);
|
||||
EXTRACTED_SCRIPTS.put(scriptName, target);
|
||||
return target;
|
||||
}
|
||||
copyResourceToFile(res, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -2,6 +2,7 @@ package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -65,6 +66,24 @@ class FileStorageOwnershipTest {
|
||||
assertThrows(SecurityException.class, () -> fs.deleteFile(id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemDeleteOfAnotherUsersFile_allowed_soJobCleanupDoesNotOrphanIt(@TempDir Path tempDir)
|
||||
throws IOException {
|
||||
// An admin sweeping every user's finished jobs is not the owner of their files. The
|
||||
// ownership-checked delete throws there, which used to drop the job record and leave the
|
||||
// files stranded on disk with nothing left able to reference them.
|
||||
AtomicReference<String> user = new AtomicReference<>("alice");
|
||||
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
|
||||
String id = fs.storeBytes("alice's file".getBytes(), "x.bin");
|
||||
user.set("admin");
|
||||
|
||||
assertThrows(SecurityException.class, () -> fs.deleteFile(id));
|
||||
assertTrue(fs.deleteFileAsSystem(id), "System delete must not be blocked by ownership");
|
||||
|
||||
user.set("alice");
|
||||
assertThrows(IOException.class, () -> fs.retrieveBytes(id), "File should really be gone");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousRetrieveOfOwnedFile_allowed_noCurrentUserMeansNoCompare(@TempDir Path tempDir)
|
||||
throws IOException {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
|
||||
/**
|
||||
* Covers the on-demand cleanup path and the input-copy tracking that makes it complete. An async
|
||||
* submit persists a copy of the upload as well as its results; before both were tracked, only the
|
||||
* results were ever deleted and the input copy stayed on disk indefinitely.
|
||||
*/
|
||||
class TaskManagerCleanupTest {
|
||||
|
||||
@Mock private FileStorage fileStorage;
|
||||
@Mock private JobStore jobStore;
|
||||
@Mock private ClusterBackplane clusterBackplane;
|
||||
|
||||
@InjectMocks private TaskManager taskManager;
|
||||
|
||||
private AutoCloseable closeable;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
closeable = MockitoAnnotations.openMocks(this);
|
||||
lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node");
|
||||
lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true);
|
||||
lenient().when(fileStorage.deleteFileAsSystem(anyString())).thenReturn(true);
|
||||
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
|
||||
ReflectionTestUtils.setField(taskManager, "pendingJobExpiryMinutes", 1440);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
closeable.close();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, JobResult> jobResults() {
|
||||
return (Map<String, JobResult>) ReflectionTestUtils.getField(taskManager, "jobResults");
|
||||
}
|
||||
|
||||
/** Complete a job with a single result file, as an async file-producing job would. */
|
||||
private void completeWithFile(String jobId, String fileId) {
|
||||
taskManager.setFileResult(jobId, fileId, "out.pdf", MediaType.APPLICATION_PDF_VALUE);
|
||||
taskManager.setComplete(jobId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void forcedCleanupRemovesFinishedJobsRegardlessOfAge() {
|
||||
String jobId = "fresh-job";
|
||||
taskManager.createTask(jobId);
|
||||
completeWithFile(jobId, "result-file");
|
||||
|
||||
// The scheduled sweep leaves it alone: it completed well inside the retention window.
|
||||
taskManager.cleanupOldJobs();
|
||||
assertTrue(jobResults().containsKey(jobId), "Scheduled cleanup should respect the expiry");
|
||||
|
||||
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
|
||||
|
||||
assertEquals(1, summary.jobsRemoved());
|
||||
assertEquals(1, summary.filesDeleted());
|
||||
assertEquals(0, summary.jobsRetained());
|
||||
assertFalse(jobResults().containsKey(jobId));
|
||||
verify(fileStorage).deleteFileAsSystem("result-file");
|
||||
verify(jobStore).delete(jobId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void forcedCleanupDeletesThePersistedInputCopy() {
|
||||
String jobId = "job-with-input";
|
||||
taskManager.createTask(jobId);
|
||||
assertTrue(taskManager.registerInputFile(jobId, "input-file"));
|
||||
completeWithFile(jobId, "result-file");
|
||||
|
||||
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
|
||||
|
||||
assertEquals(1, summary.jobsRemoved());
|
||||
assertEquals(2, summary.filesDeleted(), "Both the result and the input copy must go");
|
||||
verify(fileStorage).deleteFileAsSystem("result-file");
|
||||
verify(fileStorage).deleteFileAsSystem("input-file");
|
||||
}
|
||||
|
||||
@Test
|
||||
void scheduledCleanupAlsoDeletesThePersistedInputCopy() {
|
||||
String jobId = "expired-job";
|
||||
taskManager.createTask(jobId);
|
||||
taskManager.registerInputFile(jobId, "input-file");
|
||||
completeWithFile(jobId, "result-file");
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
ReflectionTestUtils.setField(result, "completedAt", LocalDateTime.now().minusHours(1));
|
||||
|
||||
taskManager.cleanupOldJobs();
|
||||
|
||||
assertFalse(jobResults().containsKey(jobId));
|
||||
verify(fileStorage).deleteFileAsSystem("result-file");
|
||||
verify(fileStorage).deleteFileAsSystem("input-file");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forcedCleanupLeavesRunningJobsAlone() {
|
||||
String running = "running-job";
|
||||
taskManager.createTask(running);
|
||||
taskManager.registerInputFile(running, "in-flight-input");
|
||||
|
||||
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
|
||||
|
||||
assertEquals(0, summary.jobsRemoved());
|
||||
assertEquals(0, summary.filesDeleted());
|
||||
assertEquals(1, summary.jobsRetained());
|
||||
assertTrue(jobResults().containsKey(running));
|
||||
// Deleting a running job's input mid-flight would break it.
|
||||
verify(fileStorage, never()).deleteFileAsSystem(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forcedCleanupSkipsJobsTheFilterRejects() {
|
||||
taskManager.createTask("alice:job");
|
||||
completeWithFile("alice:job", "alice-file");
|
||||
taskManager.createTask("bob:job");
|
||||
completeWithFile("bob:job", "bob-file");
|
||||
|
||||
TaskManager.CleanupSummary summary =
|
||||
taskManager.cleanupFinishedJobsNow(id -> id.startsWith("alice:"));
|
||||
|
||||
assertEquals(1, summary.jobsRemoved());
|
||||
assertEquals(1, summary.jobsRetained());
|
||||
assertFalse(jobResults().containsKey("alice:job"));
|
||||
assertTrue(jobResults().containsKey("bob:job"), "Another user's job must survive");
|
||||
verify(fileStorage).deleteFileAsSystem("alice-file");
|
||||
verify(fileStorage, never()).deleteFileAsSystem("bob-file");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forcedCleanupIsIdempotent() {
|
||||
String jobId = "job-to-clean";
|
||||
taskManager.createTask(jobId);
|
||||
taskManager.registerInputFile(jobId, "input-file");
|
||||
completeWithFile(jobId, "result-file");
|
||||
|
||||
taskManager.cleanupFinishedJobsNow(id -> true);
|
||||
TaskManager.CleanupSummary second = taskManager.cleanupFinishedJobsNow(id -> true);
|
||||
|
||||
assertEquals(0, second.jobsRemoved());
|
||||
assertEquals(0, second.filesDeleted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forcedCleanupRunsEvenWhenTheBackplaneOwnsScheduledExpiry() {
|
||||
// The scheduled sweep defers to the backplane TTL in cluster mode, but an explicit
|
||||
// request to release this node's storage still has to do something.
|
||||
when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(false);
|
||||
String jobId = "clustered-job";
|
||||
taskManager.createTask(jobId);
|
||||
completeWithFile(jobId, "result-file");
|
||||
|
||||
taskManager.cleanupOldJobs();
|
||||
assertTrue(jobResults().containsKey(jobId));
|
||||
|
||||
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
|
||||
|
||||
assertEquals(1, summary.jobsRemoved());
|
||||
assertFalse(jobResults().containsKey(jobId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupCountsOnlyFilesThatWereActuallyDeleted() {
|
||||
// A file already gone (a retry deleted it, say) must not be counted as freed.
|
||||
String jobId = "partially-cleaned";
|
||||
taskManager.createTask(jobId);
|
||||
taskManager.registerInputFile(jobId, "already-gone");
|
||||
completeWithFile(jobId, "result-file");
|
||||
when(fileStorage.deleteFileAsSystem("already-gone")).thenReturn(false);
|
||||
|
||||
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
|
||||
|
||||
assertEquals(1, summary.filesDeleted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupSurvivesAFileStorageFailure() {
|
||||
String jobId = "job-with-unhappy-storage";
|
||||
taskManager.createTask(jobId);
|
||||
taskManager.registerInputFile(jobId, "input-file");
|
||||
completeWithFile(jobId, "result-file");
|
||||
when(fileStorage.deleteFileAsSystem("result-file"))
|
||||
.thenThrow(new RuntimeException("disk on fire"));
|
||||
|
||||
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
|
||||
|
||||
// The job is still released and the remaining file still deleted.
|
||||
assertEquals(1, summary.jobsRemoved());
|
||||
assertEquals(1, summary.filesDeleted());
|
||||
assertFalse(jobResults().containsKey(jobId));
|
||||
verify(fileStorage).deleteFileAsSystem("input-file");
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerInputFileRejectsAnUnknownJob() {
|
||||
assertFalse(taskManager.registerInputFile("no-such-job", "input-file"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerInputFileIgnoresDuplicatesAndBlanks() {
|
||||
String jobId = "dedupe-job";
|
||||
taskManager.createTask(jobId);
|
||||
taskManager.registerInputFile(jobId, "input-file");
|
||||
taskManager.registerInputFile(jobId, "input-file");
|
||||
taskManager.registerInputFile(jobId, " ");
|
||||
taskManager.registerInputFile(jobId, null);
|
||||
|
||||
List<String> inputFileIds = taskManager.getJobResult(jobId).getInputFileIds();
|
||||
|
||||
assertEquals(List.of("input-file"), inputFileIds);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiFileResultsAndTheInputCopyAreAllDeleted() {
|
||||
String jobId = "split-job";
|
||||
taskManager.createTask(jobId);
|
||||
taskManager.registerInputFile(jobId, "input-file");
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
result.completeWithFiles(
|
||||
List.of(
|
||||
ResultFile.builder()
|
||||
.fileId("page-1")
|
||||
.fileName("1.pdf")
|
||||
.contentType(MediaType.APPLICATION_PDF_VALUE)
|
||||
.fileSize(10L)
|
||||
.build(),
|
||||
ResultFile.builder()
|
||||
.fileId("page-2")
|
||||
.fileName("2.pdf")
|
||||
.contentType(MediaType.APPLICATION_PDF_VALUE)
|
||||
.fileSize(10L)
|
||||
.build()));
|
||||
|
||||
TaskManager.CleanupSummary summary = taskManager.cleanupFinishedJobsNow(id -> true);
|
||||
|
||||
assertEquals(3, summary.filesDeleted());
|
||||
verify(fileStorage).deleteFileAsSystem("page-1");
|
||||
verify(fileStorage).deleteFileAsSystem("page-2");
|
||||
verify(fileStorage).deleteFileAsSystem("input-file");
|
||||
}
|
||||
}
|
||||
@@ -290,7 +290,8 @@ class TaskManagerMoreTest {
|
||||
ReflectionTestUtils.setField(job, "complete", true);
|
||||
ReflectionTestUtils.setField(job, "completedAt", LocalDateTime.now().minusHours(2));
|
||||
|
||||
when(fileStorage.deleteFile("doomed")).thenThrow(new RuntimeException("locked"));
|
||||
when(fileStorage.deleteFileAsSystem("doomed"))
|
||||
.thenThrow(new RuntimeException("locked"));
|
||||
|
||||
// Must not propagate; the job is still removed afterwards.
|
||||
taskManager.cleanupOldJobs();
|
||||
|
||||
@@ -258,7 +258,7 @@ class TaskManagerTest {
|
||||
.build();
|
||||
ReflectionTestUtils.setField(oldJob, "resultFiles", java.util.List.of(resultFile));
|
||||
|
||||
when(fileStorage.deleteFile("file-id")).thenReturn(true);
|
||||
when(fileStorage.deleteFileAsSystem("file-id")).thenReturn(true);
|
||||
|
||||
// Obtain access to the private jobResults map
|
||||
Map<String, JobResult> jobResultsMap =
|
||||
@@ -281,7 +281,7 @@ class TaskManagerTest {
|
||||
assertFalse(jobResultsMap.containsKey(oldJobId));
|
||||
assertTrue(jobResultsMap.containsKey(recentJobId));
|
||||
assertTrue(jobResultsMap.containsKey(activeJobId));
|
||||
verify(fileStorage).deleteFile("file-id");
|
||||
verify(fileStorage).deleteFileAsSystem("file-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -308,7 +308,7 @@ class TaskManagerTest {
|
||||
// Assert: nothing was removed locally, and no jobStore.delete was issued.
|
||||
assertTrue(jobResultsMap.containsKey(oldJobId));
|
||||
verify(jobStore, never()).delete(anyString());
|
||||
verify(fileStorage, never()).deleteFile(anyString());
|
||||
verify(fileStorage, never()).deleteFileAsSystem(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPageTree;
|
||||
@@ -43,6 +48,10 @@ import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.common.util.propertyeditor.JsonListPropertyEditor;
|
||||
import stirling.software.common.util.propertyeditor.JsonObjectPropertyEditor;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.redact.PdfRedactor;
|
||||
import stirling.software.jpdfium.redact.RedactOptions;
|
||||
import stirling.software.jpdfium.redact.RedactResult;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
|
||||
@@ -140,134 +149,138 @@ public class RedactController {
|
||||
+ " patterns. Users can provide text patterns to redact, with options for regex"
|
||||
+ " and whole word matching.")
|
||||
public ResponseEntity<Resource> redactPdf(@ModelAttribute RedactPdfRequest request) {
|
||||
String rawListOfText = request.getListOfText();
|
||||
boolean useRegex = Boolean.TRUE.equals(request.getUseRegex());
|
||||
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
|
||||
if (request.getFileInput() == null || request.getFileInput().isEmpty()) {
|
||||
log.error("File input is null or empty");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
|
||||
String rawListOfText = request.getListOfText();
|
||||
if (rawListOfText == null || rawListOfText.trim().isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "No text patterns provided for redaction");
|
||||
}
|
||||
|
||||
String[] listOfText = rawListOfText.split("\n");
|
||||
if (listOfText.length == 1 && listOfText[0].trim().isEmpty()) {
|
||||
List<String> terms =
|
||||
Arrays.stream(rawListOfText.split("\n"))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty() && s.length() <= 4096)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (terms.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "No text patterns provided for redaction");
|
||||
}
|
||||
|
||||
PDDocument document = null;
|
||||
PDDocument fallbackDocument = null;
|
||||
boolean useRegex = Boolean.TRUE.equals(request.getUseRegex());
|
||||
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
|
||||
|
||||
try {
|
||||
if (request.getFileInput() == null) {
|
||||
log.error("File input is null");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
if (useRegex) {
|
||||
for (String term : terms) {
|
||||
try {
|
||||
Pattern.compile(term);
|
||||
} catch (PatternSyntaxException e) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "Invalid regex pattern: " + term);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document = pdfDocumentFactory.load(request.getFileInput());
|
||||
String filename =
|
||||
removeFileExtension(
|
||||
Objects.requireNonNull(
|
||||
Filenames.toSimpleFileName(
|
||||
request.getFileInput().getOriginalFilename())))
|
||||
+ "_redacted.pdf";
|
||||
|
||||
Color redactColor = ManualRedactionService.decodeOrDefault(request.getRedactColor());
|
||||
int boxColorInt = redactColor.getRGB();
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(request.getFileInput())) {
|
||||
if (document == null) {
|
||||
log.error("Failed to load PDF document");
|
||||
throw ExceptionUtils.createPdfCorruptedException(
|
||||
"during redaction", new IOException("Failed to load PDF document"));
|
||||
}
|
||||
|
||||
Map<Integer, List<PDFText>> allFoundTextsByPage =
|
||||
textRedactionService.findTextToRedact(
|
||||
document, listOfText, useRegex, wholeWordSearchBool);
|
||||
try (TempFile tempInput = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
try {
|
||||
request.getFileInput().transferTo(tempInput.getFile());
|
||||
} catch (Exception e) {
|
||||
document.save(tempInput.getFile());
|
||||
}
|
||||
|
||||
int totalMatches = allFoundTextsByPage.values().stream().mapToInt(List::size).sum();
|
||||
log.info(
|
||||
"Redaction scan: {} occurrences across {} pages (patterns={}, regex={}, wholeWord={})",
|
||||
totalMatches,
|
||||
allFoundTextsByPage.size(),
|
||||
listOfText.length,
|
||||
useRegex,
|
||||
wholeWordSearchBool);
|
||||
RedactOptions options =
|
||||
RedactOptions.builder()
|
||||
.addWords(terms)
|
||||
.useRegex(useRegex)
|
||||
.wholeWord(wholeWordSearchBool)
|
||||
.boxColor(boxColorInt)
|
||||
.padding(request.getCustomPadding())
|
||||
.removeContent(true)
|
||||
.convertToImage(Boolean.TRUE.equals(request.getConvertPDFToImage()))
|
||||
.normalizeFonts(false)
|
||||
.fixToUnicode(false)
|
||||
.glyphAware(true)
|
||||
.redactMetadata(true)
|
||||
.build();
|
||||
|
||||
String filename =
|
||||
removeFileExtension(
|
||||
Objects.requireNonNull(
|
||||
Filenames.toSimpleFileName(
|
||||
request.getFileInput().getOriginalFilename())))
|
||||
+ "_redacted.pdf";
|
||||
TempFile tempOutput = tempFileManager.createManagedTempFile(".pdf");
|
||||
try {
|
||||
try (PdfDocument checkDoc = PdfDocument.open(tempInput.getFile().toPath())) {
|
||||
if (checkDoc.pageCount() <= 0) {
|
||||
throw new IOException("Invalid or empty PDF document");
|
||||
}
|
||||
}
|
||||
|
||||
if (allFoundTextsByPage.isEmpty()) {
|
||||
log.info("No text found matching redaction patterns");
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, filename, tempFileManager);
|
||||
log.debug(
|
||||
"Calling JPDFium PdfRedactor.redact in RedactController (terms={})",
|
||||
terms);
|
||||
RedactResult result = PdfRedactor.redact(tempInput.getFile().toPath(), options);
|
||||
log.debug(
|
||||
"JPDFium auto-redact complete (matches={})",
|
||||
result != null ? result.totalMatches() : -1);
|
||||
if (result == null) {
|
||||
throw new IOException("JPDFium auto-redact returned null result");
|
||||
}
|
||||
try {
|
||||
result.save(tempOutput.getFile().toPath());
|
||||
log.info(
|
||||
"JPDFium auto-redact: {} matches processed into {}",
|
||||
result.totalMatches(),
|
||||
filename);
|
||||
return WebResponseUtils.pdfFileToWebResponse(tempOutput, filename);
|
||||
} finally {
|
||||
if (result.document() != null) {
|
||||
result.document().close();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
tempOutput.close();
|
||||
log.warn(
|
||||
"JPDFium native redaction fell back to manual redaction service: {}",
|
||||
e.getMessage());
|
||||
Map<Integer, List<PDFText>> foundTexts =
|
||||
textRedactionService.findTextToRedact(
|
||||
document,
|
||||
terms.toArray(new String[0]),
|
||||
useRegex,
|
||||
wholeWordSearchBool);
|
||||
TempFile finalized =
|
||||
manualRedactionService.finalizeRedaction(
|
||||
document,
|
||||
foundTexts,
|
||||
request.getRedactColor(),
|
||||
request.getCustomPadding(),
|
||||
request.getConvertPDFToImage(),
|
||||
false);
|
||||
return WebResponseUtils.pdfFileToWebResponse(finalized, filename);
|
||||
}
|
||||
}
|
||||
|
||||
boolean fallbackToBoxOnlyMode;
|
||||
try {
|
||||
fallbackToBoxOnlyMode =
|
||||
textRedactionService.performTextReplacement(
|
||||
document,
|
||||
allFoundTextsByPage,
|
||||
listOfText,
|
||||
useRegex,
|
||||
wholeWordSearchBool);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Text replacement redaction failed, falling back to box-only mode: {}",
|
||||
e.getMessage());
|
||||
fallbackToBoxOnlyMode = true;
|
||||
}
|
||||
|
||||
if (fallbackToBoxOnlyMode) {
|
||||
log.warn(
|
||||
"Font compatibility issues detected. Using box-only redaction mode for better reliability.");
|
||||
|
||||
fallbackDocument = pdfDocumentFactory.load(request.getFileInput());
|
||||
|
||||
allFoundTextsByPage =
|
||||
textRedactionService.findTextToRedact(
|
||||
fallbackDocument, listOfText, useRegex, wholeWordSearchBool);
|
||||
|
||||
TempFile finalized =
|
||||
manualRedactionService.finalizeRedaction(
|
||||
fallbackDocument,
|
||||
allFoundTextsByPage,
|
||||
request.getRedactColor(),
|
||||
request.getCustomPadding(),
|
||||
request.getConvertPDFToImage(),
|
||||
false);
|
||||
|
||||
return WebResponseUtils.pdfFileToWebResponse(finalized, filename);
|
||||
}
|
||||
|
||||
TempFile finalized =
|
||||
manualRedactionService.finalizeRedaction(
|
||||
document,
|
||||
allFoundTextsByPage,
|
||||
request.getRedactColor(),
|
||||
request.getCustomPadding(),
|
||||
request.getConvertPDFToImage(),
|
||||
true);
|
||||
|
||||
return WebResponseUtils.pdfFileToWebResponse(finalized, filename);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("Redaction operation failed: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("Failed to perform PDF redaction: " + e.getMessage(), e);
|
||||
|
||||
} finally {
|
||||
if (document != null) {
|
||||
try {
|
||||
if (fallbackDocument == null) {
|
||||
document.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to close main document: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackDocument != null) {
|
||||
try {
|
||||
fallbackDocument.close();
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to close fallback document: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,5 +45,5 @@ public class RedactPdfRequest extends PDFFile {
|
||||
description = "Convert the redacted PDF to an image",
|
||||
defaultValue = "false",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean convertPDFToImage;
|
||||
private Boolean convertPDFToImage = Boolean.FALSE;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -213,6 +214,35 @@ public class JobController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-service counterpart to the admin-only {@code POST /api/v1/admin/job/cleanup}: that one
|
||||
* sweeps every user's jobs and needs ROLE_ADMIN, this one releases only the caller's own and so
|
||||
* is safe for any authenticated user. Both run the same sweep inside {@link TaskManager}.
|
||||
*/
|
||||
@PostMapping("/jobs/cleanup")
|
||||
@Operation(
|
||||
summary = "Release finished jobs and their stored files now",
|
||||
description =
|
||||
"Force-expires this node's finished jobs instead of waiting out the retention"
|
||||
+ " window, deleting their result files and the persistent copies made of"
|
||||
+ " their inputs. Only jobs the caller may access are touched, and jobs"
|
||||
+ " still running are left alone. Admins can sweep every user's jobs"
|
||||
+ " with POST /api/v1/admin/job/cleanup?force=true.")
|
||||
public ResponseEntity<?> cleanupFinishedJobs() {
|
||||
TaskManager.CleanupSummary summary =
|
||||
taskManager.cleanupFinishedJobsNow(this::validateJobAccess);
|
||||
log.info(
|
||||
"On-demand job cleanup removed {} job(s) and {} file(s), retained {} job(s)",
|
||||
summary.jobsRemoved(),
|
||||
summary.filesDeleted(),
|
||||
summary.jobsRetained());
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"jobsRemoved", summary.jobsRemoved(),
|
||||
"filesDeleted", summary.filesDeleted(),
|
||||
"jobsRetained", summary.jobsRetained()));
|
||||
}
|
||||
|
||||
@GetMapping("/job/{jobId}/result/files")
|
||||
@Operation(summary = "Get job result files")
|
||||
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
|
||||
|
||||
@@ -313,7 +313,7 @@ autoPipeline:
|
||||
|
||||
ui:
|
||||
appNameNavbar: "" # custom app/brand name. NOTE: no longer shown in the navbar (the navbar renders the logo). It IS used as the browser tab title and as the TOTP/2FA issuer label in authenticator apps. Empty falls back to "Stirling PDF"
|
||||
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
|
||||
logoStyle: modern # Options: 'modern' (default - minimalist logo) or 'classic' (legacy S icon)
|
||||
languages: [] # If empty, all languages are enabled. To restrict to specific languages, use a whitelist like ["de_DE", "pl_PL", "sv_SE"]. Empty list or not restricting any languages will enable all available languages.
|
||||
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
|
||||
defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 829 B After Width: | Height: | Size: 681 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
@@ -1 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><defs id="defs173"><linearGradient id="XMLID_5_" x1="304.496" x2="316.036" y1="422.91" y2="326.263" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop156"/><stop offset="1" style="stop-color:#c2c2c9" id="stop158"/></linearGradient></defs><style id="style150" type="text/css">.st1{fill:#c02223}.st2{fill:#882425}.st3{fill:url(#XMLID_5_)}.st4{fill:url(#XMLID_7_)}</style><g id="XMLID_4_"><path id="XMLID_131_" d="M 347.01402,14.355825 98.978019,69.02261 C 73.825483,74.547445 55.942464,96.792175 55.942464,122.52628 v 315.06096 c 0,22.39012 16.719895,41.14548 38.819234,43.76251 L 224.8861,498.36042 339.48636,384.26465 455.76603,265.15425 453.73057,84.870162 C 453.43979,62.916214 433.08513,46.632491 411.71274,51.284984 l -28.78729,6.251786 0.14539,-13.666697 C 383.36162,24.678542 365.62399,10.284894 347.01402,14.355825 Z" class="st1" style="stroke-width:1.45391"/><path id="XMLID_117_" d="m 383.21622,57.53677 v 285.8375 L 456.05681,265.00885 454.02135,78.763767 C 453.87595,59.863016 436.28372,45.905539 417.81914,49.97647 Z" class="st2" style="stroke-width:1.45391"/><polygon id="XMLID_18_" points="234.7 422.6 368.5 387.7 393.5 262.2" class="st3" style="fill:url(#XMLID_5_)" transform="matrix(1.4556308,0,0,1.4548265,-116.73161,-116.45231)"/><linearGradient id="XMLID_7_" x1="223.084" x2="241.417" y1="372.756" y2="114.557" gradientTransform="matrix(1.4539039,0,0,1.4539039,-116.19976,-116.20474)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop163"/><stop offset="1" style="stop-color:#c2c2c9" id="stop165"/></linearGradient><path id="XMLID_6_" d="m 282.89686,214.84917 c 0,0 -22.24473,-28.93269 -38.67384,-36.78377 -10.46811,-4.94327 -26.02489,-6.83335 -38.23768,-0.72695 -18.02841,9.0142 -19.91848,34.31213 -3.34397,44.34406 3.92553,2.47165 9.15959,4.50711 15.99294,6.10641 36.63838,8.43264 97.12077,25.87949 89.70587,96.10304 0,0 -4.21633,65.86185 -73.56753,73.42215 -12.2128,1.30851 -24.57098,0.43617 -36.493,-2.32625 -16.42911,-3.63476 -45.50719,-11.04967 -59.75545,-19.91849 l -2.61703,-75.16682 h 6.97875 c 0,0 13.81208,33.43978 53.06749,49.57812 7.26952,2.90781 15.26599,4.07093 22.97168,2.90781 9.74116,-1.45391 21.22699,-6.68796 25.87949,-22.53551 0,0 7.85108,-23.11707 -32.85823,-35.76604 -32.56744,-10.17733 -63.24481,-20.64543 -75.89378,-54.95757 -5.961,-16.28371 -6.97874,-34.31212 -2.90781,-51.61358 5.37944,-22.53551 20.79082,-54.23062 64.40794,-67.89732 0,0 57.28381,-15.55677 96.53922,5.52484 l -1.74468,89.70587 z" class="st4" style="fill:url(#XMLID_7_);stroke-width:1.45391"/></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" rx="80" ry="80" fill="#8E3131"/>
|
||||
<path d="M202 268L432 78V255L202 445V268Z" fill="#FFFFFF" fill-opacity="0.6"/>
|
||||
<path d="M79 256L309 66V242L79 432V256Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 302 B |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
@@ -61,7 +61,10 @@ class ToolIODeclarationCoverageTest {
|
||||
// signing tool itself is /api/v1/security/cert-sign, which is declared.
|
||||
"/api/v1/security/cert-sign/sessions",
|
||||
"/api/v1/security/cert-sign/validate-certificate",
|
||||
"/api/v1/security/cert-sign/hardware");
|
||||
"/api/v1/security/cert-sign/hardware",
|
||||
// Releases finished jobs and their stored files; server maintenance, takes and
|
||||
// returns no document.
|
||||
"/api/v1/general/jobs/cleanup");
|
||||
|
||||
private record Scan(Set<String> required, Map<String, ToolIOSpec> declared) {}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ 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.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -120,12 +122,22 @@ class RedactControllerMoreTest {
|
||||
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
|
||||
}
|
||||
|
||||
private PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private byte[] singlePageTextPdf(String... lines) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f);
|
||||
@@ -145,7 +157,7 @@ class RedactControllerMoreTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y);
|
||||
cs.showText(line);
|
||||
|
||||
@@ -14,19 +14,25 @@ import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.contentstream.operator.Operator;
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSDocument;
|
||||
import org.apache.pdfbox.cos.COSFloat;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSStream;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDPageTree;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -49,6 +55,7 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest;
|
||||
import stirling.software.SPDF.model.api.security.RedactPdfRequest;
|
||||
import stirling.software.common.model.api.security.RedactionArea;
|
||||
@@ -64,9 +71,9 @@ class RedactControllerTest {
|
||||
return ResponseEntity.ok(new ByteArrayResource(bytes));
|
||||
}
|
||||
|
||||
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
try (java.io.InputStream __in = response.getBody().getInputStream()) {
|
||||
private static byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream __in = response.getBody().getInputStream()) {
|
||||
__in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
@@ -90,13 +97,24 @@ class RedactControllerTest {
|
||||
private PDDocument realDocument;
|
||||
private PDPage realPage;
|
||||
|
||||
private static PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
RedactControllerTest.class.getResourceAsStream(
|
||||
"/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private static byte[] createSimplePdfContent() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(doc, page)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
contentStream.setFont(helvetica(doc), 12);
|
||||
contentStream.newLineAtOffset(100, 700);
|
||||
contentStream.showText("This is a simple PDF.");
|
||||
contentStream.endText();
|
||||
@@ -156,8 +174,7 @@ class RedactControllerTest {
|
||||
mockDocument = mock(PDDocument.class);
|
||||
mockPages = mock(PDPageTree.class);
|
||||
mockPage = mock(PDPage.class);
|
||||
org.apache.pdfbox.pdmodel.PDDocumentCatalog mockCatalog =
|
||||
mock(org.apache.pdfbox.pdmodel.PDDocumentCatalog.class);
|
||||
PDDocumentCatalog mockCatalog = mock(PDDocumentCatalog.class);
|
||||
|
||||
// Setup document structure properly
|
||||
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDocument);
|
||||
@@ -182,9 +199,8 @@ class RedactControllerTest {
|
||||
|
||||
when(mockPage.hasContents()).thenReturn(true);
|
||||
|
||||
org.apache.pdfbox.cos.COSDocument mockCOSDocument =
|
||||
mock(org.apache.pdfbox.cos.COSDocument.class);
|
||||
org.apache.pdfbox.cos.COSStream mockCOSStream = mock(org.apache.pdfbox.cos.COSStream.class);
|
||||
COSDocument mockCOSDocument = mock(COSDocument.class);
|
||||
COSStream mockCOSStream = mock(COSStream.class);
|
||||
when(mockDocument.getDocument()).thenReturn(mockCOSDocument);
|
||||
when(mockCOSDocument.createCOSStream()).thenReturn(mockCOSStream);
|
||||
|
||||
@@ -338,17 +354,13 @@ class RedactControllerTest {
|
||||
|
||||
when(mockPages.get(0)).thenReturn(mockPage);
|
||||
|
||||
org.apache.pdfbox.pdmodel.PDDocumentInformation mockInfo =
|
||||
mock(org.apache.pdfbox.pdmodel.PDDocumentInformation.class);
|
||||
PDDocumentInformation mockInfo = mock(PDDocumentInformation.class);
|
||||
when(mockDocument.getDocumentInformation()).thenReturn(mockInfo);
|
||||
|
||||
ResponseEntity<Resource> response = redactController.redactPdf(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
|
||||
verify(mockDocument).save(any(File.class));
|
||||
verify(mockDocument).close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,8 +765,6 @@ class RedactControllerTest {
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(drainBody(response).length > 0);
|
||||
verify(mockDocument, times(1)).save(any(File.class));
|
||||
verify(mockDocument, times(1)).close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (expectSuccess) {
|
||||
@@ -788,11 +798,14 @@ class RedactControllerTest {
|
||||
realPage = new PDPage(PDRectangle.A4);
|
||||
realDocument.addPage(realPage);
|
||||
|
||||
// Set up basic page resources
|
||||
PDResources resources = new PDResources();
|
||||
resources.put(
|
||||
COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
realPage.setResources(resources);
|
||||
// Set up basic page resources with embedded font
|
||||
try {
|
||||
PDResources resources = new PDResources();
|
||||
resources.put(COSName.getPDFName("F1"), helvetica(realDocument));
|
||||
realPage.setResources(resources);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for real PDF content creation
|
||||
@@ -803,8 +816,7 @@ class RedactControllerTest {
|
||||
}
|
||||
realDocument.addPage(realPage);
|
||||
realPage.setResources(new PDResources());
|
||||
realPage.getResources()
|
||||
.put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
realPage.getResources().put(COSName.getPDFName("F1"), helvetica(realDocument));
|
||||
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) {
|
||||
contentStream.beginText();
|
||||
@@ -822,8 +834,7 @@ class RedactControllerTest {
|
||||
}
|
||||
realDocument.addPage(realPage);
|
||||
realPage.setResources(new PDResources());
|
||||
realPage.getResources()
|
||||
.put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
realPage.getResources().put(COSName.getPDFName("F1"), helvetica(realDocument));
|
||||
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) {
|
||||
contentStream.beginText();
|
||||
@@ -846,8 +857,7 @@ class RedactControllerTest {
|
||||
}
|
||||
realDocument.addPage(realPage);
|
||||
realPage.setResources(new PDResources());
|
||||
realPage.getResources()
|
||||
.put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
realPage.getResources().put(COSName.getPDFName("F1"), helvetica(realDocument));
|
||||
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) {
|
||||
contentStream.setLineWidth(2);
|
||||
@@ -874,8 +884,7 @@ class RedactControllerTest {
|
||||
}
|
||||
realDocument.addPage(realPage);
|
||||
realPage.setResources(new PDResources());
|
||||
realPage.getResources()
|
||||
.put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
realPage.getResources().put(COSName.getPDFName("F1"), helvetica(realDocument));
|
||||
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) {
|
||||
contentStream.beginText();
|
||||
@@ -1005,22 +1014,6 @@ class RedactControllerTest {
|
||||
}
|
||||
}
|
||||
|
||||
private List<Object> getOriginalTokens() throws Exception {
|
||||
// Create a new page to avoid side effects from other tests
|
||||
PDPage pageForTokenExtraction = new PDPage(PDRectangle.A4);
|
||||
pageForTokenExtraction.setResources(realPage.getResources());
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(realDocument, pageForTokenExtraction)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(realPage.getResources().getFont(COSName.getPDFName("F1")), 12);
|
||||
contentStream.newLineAtOffset(50, 750);
|
||||
contentStream.showText("Original content");
|
||||
contentStream.endText();
|
||||
}
|
||||
return textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, pageForTokenExtraction, Collections.emptySet(), false, false);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Color Decoding Utility Tests")
|
||||
class ColorDecodingTests {
|
||||
@@ -1099,318 +1092,19 @@ class RedactControllerTest {
|
||||
class ContentStreamUnitTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("createTokensWithoutTargetText should remove simple text tokens")
|
||||
void shouldRemoveSimpleTextTokens() throws Exception {
|
||||
createRealPageWithSimpleText("This document contains confidential information.");
|
||||
@DisplayName("performTextReplacement should process document text replacement")
|
||||
void shouldPerformTextReplacement() throws Exception {
|
||||
createRealPageWithSimpleText("This document contains sensitive information.");
|
||||
String[] targetWords = new String[] {"sensitive"};
|
||||
|
||||
Set<String> targetWords = Set.of("confidential");
|
||||
Map<Integer, List<PDFText>> found =
|
||||
textRedactionService.findTextToRedact(realDocument, targetWords, false, false);
|
||||
assertFalse(found.isEmpty(), "Should find target text to redact");
|
||||
|
||||
List<Object> tokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
assertNotNull(tokens);
|
||||
assertFalse(tokens.isEmpty());
|
||||
|
||||
String reconstructedText = extractTextFromTokens(tokens);
|
||||
assertFalse(
|
||||
reconstructedText.contains("confidential"),
|
||||
"Target text should be replaced with placeholder");
|
||||
assertTrue(reconstructedText.contains("document"), "Non-target text should remain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createTokensWithoutTargetText should handle TJ operator arrays")
|
||||
void shouldHandleTJOperatorArrays() throws Exception {
|
||||
createRealPageWithTJArrayText();
|
||||
|
||||
Set<String> targetWords = Set.of("secret");
|
||||
|
||||
List<Object> tokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
assertNotNull(tokens);
|
||||
|
||||
boolean foundModifiedTJArray = false;
|
||||
for (Object token : tokens) {
|
||||
if (token instanceof COSArray array) {
|
||||
for (int i = 0; i < array.size(); i++) {
|
||||
if (array.getObject(i) instanceof COSString cosString) {
|
||||
String text = cosString.getString();
|
||||
if (text.contains("secret")) {
|
||||
fail(
|
||||
"Target text 'secret' should have been redacted from TJ"
|
||||
+ " array");
|
||||
}
|
||||
foundModifiedTJArray = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(foundModifiedTJArray, "Should find at least one TJ array");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createTokensWithoutTargetText should preserve non-text tokens")
|
||||
void shouldPreserveNonTextTokens() throws Exception {
|
||||
createRealPageWithMixedContent();
|
||||
|
||||
Set<String> targetWords = Set.of("redact");
|
||||
|
||||
List<Object> originalTokens = getOriginalTokens();
|
||||
List<Object> filteredTokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
long originalNonTextCount =
|
||||
originalTokens.stream()
|
||||
.filter(
|
||||
token ->
|
||||
token instanceof Operator op
|
||||
&& !textRedactionService.isTextShowingOperator(
|
||||
op.getName()))
|
||||
.count();
|
||||
|
||||
long filteredNonTextCount =
|
||||
filteredTokens.stream()
|
||||
.filter(
|
||||
token ->
|
||||
token instanceof Operator op
|
||||
&& !textRedactionService.isTextShowingOperator(
|
||||
op.getName()))
|
||||
.count();
|
||||
|
||||
assertTrue(filteredNonTextCount > 0, "Non-text operators should be preserved");
|
||||
|
||||
assertTrue(
|
||||
filteredNonTextCount >= originalNonTextCount / 2,
|
||||
"A reasonable number of non-text operators should be preserved");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createTokensWithoutTargetText should handle regex patterns")
|
||||
void shouldHandleRegexPatterns() throws Exception {
|
||||
createRealPageWithSimpleText("Phone: 123-456-7890 and SSN: 111-22-3333");
|
||||
|
||||
Set<String> targetWords = Set.of("\\d{3}-\\d{2}-\\d{4}"); // SSN pattern
|
||||
|
||||
List<Object> tokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, true, false);
|
||||
|
||||
String reconstructedText = extractTextFromTokens(tokens);
|
||||
assertFalse(reconstructedText.contains("111-22-3333"), "SSN should be redacted");
|
||||
assertTrue(reconstructedText.contains("123-456-7890"), "Phone should remain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createTokensWithoutTargetText should handle whole word search")
|
||||
void shouldHandleWholeWordSearch() throws Exception {
|
||||
createRealPageWithSimpleText("This test testing tested document");
|
||||
|
||||
Set<String> targetWords = Set.of("test");
|
||||
|
||||
List<Object> tokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, true);
|
||||
|
||||
String reconstructedText = extractTextFromTokens(tokens);
|
||||
assertTrue(reconstructedText.contains("testing"), "Partial matches should remain");
|
||||
assertTrue(reconstructedText.contains("tested"), "Partial matches should remain");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"Tj", "TJ", "'", "\""})
|
||||
@DisplayName("createTokensWithoutTargetText should handle all text operators")
|
||||
void shouldHandleAllTextOperators(String operatorName) throws Exception {
|
||||
createRealPageWithSpecificOperator(operatorName);
|
||||
|
||||
Set<String> targetWords = Set.of("sensitive");
|
||||
|
||||
List<Object> tokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
String reconstructedText = extractTextFromTokens(tokens);
|
||||
assertFalse(
|
||||
reconstructedText.contains("sensitive"),
|
||||
"Text should be redacted regardless of operator type");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writeFilteredContentStream should write tokens to new stream")
|
||||
void shouldWriteTokensToNewContentStream() throws Exception {
|
||||
List<Object> tokens = createSampleTokenList();
|
||||
|
||||
textRedactionService.writeFilteredContentStream(realDocument, realPage, tokens);
|
||||
|
||||
assertNotNull(realPage.getContents(), "Page should have content stream");
|
||||
|
||||
// Verify the content can be read back
|
||||
try (InputStream inputStream = realPage.getContents()) {
|
||||
byte[] content = readAllBytes(inputStream);
|
||||
assertTrue(content.length > 0, "Content stream should not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writeFilteredContentStream should handle empty token list")
|
||||
void shouldHandleEmptyTokenList() throws Exception {
|
||||
List<Object> emptyTokens = Collections.emptyList();
|
||||
|
||||
assertDoesNotThrow(
|
||||
() ->
|
||||
textRedactionService.writeFilteredContentStream(
|
||||
realDocument, realPage, emptyTokens));
|
||||
|
||||
assertNotNull(realPage.getContents(), "Page should still have content stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writeFilteredContentStream should replace existing content")
|
||||
void shouldReplaceExistingContentStream() throws Exception {
|
||||
createRealPageWithSimpleText("Original content");
|
||||
String originalContent = extractTextFromModifiedPage(realPage);
|
||||
|
||||
List<Object> newTokens = createSampleTokenList();
|
||||
textRedactionService.writeFilteredContentStream(realDocument, realPage, newTokens);
|
||||
|
||||
String newContent = extractTextFromModifiedPage(realPage);
|
||||
assertNotEquals(originalContent, newContent, "Content stream should be replaced");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Placeholder creation should maintain text width")
|
||||
void shouldCreateWidthMatchingPlaceholder() {
|
||||
String originalText = "confidential";
|
||||
String placeholder =
|
||||
textRedactionService.createPlaceholderWithFont(
|
||||
originalText, new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
|
||||
assertEquals(
|
||||
originalText.length(),
|
||||
placeholder.length(),
|
||||
"Placeholder should maintain character count for width preservation");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Placeholder should handle special characters")
|
||||
void shouldHandleSpecialCharactersInPlaceholder() {
|
||||
String originalText = "café naïve";
|
||||
String placeholder =
|
||||
textRedactionService.createPlaceholderWithFont(
|
||||
originalText, new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
|
||||
assertEquals(originalText.length(), placeholder.length());
|
||||
assertFalse(
|
||||
placeholder.contains("café"), "Placeholder should not contain original text");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Integration test: createTokens and writeStream")
|
||||
void shouldIntegrateTokenCreationAndWriting() throws Exception {
|
||||
createRealPageWithSimpleText("This document contains secret information.");
|
||||
|
||||
Set<String> targetWords = Set.of("secret");
|
||||
|
||||
List<Object> filteredTokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
textRedactionService.writeFilteredContentStream(realDocument, realPage, filteredTokens);
|
||||
assertNotNull(realPage.getContents());
|
||||
|
||||
String finalText = extractTextFromModifiedPage(realPage);
|
||||
assertFalse(finalText.contains("secret"), "Target text should be completely removed");
|
||||
assertTrue(finalText.contains("document"), "Other text should remain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should preserve text positioning operators")
|
||||
void shouldPreserveTextPositioning() throws Exception {
|
||||
createRealPageWithPositionedText();
|
||||
|
||||
Set<String> targetWords = Set.of("confidential");
|
||||
|
||||
List<Object> filteredTokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
long filteredPositioning =
|
||||
filteredTokens.stream()
|
||||
.filter(
|
||||
token ->
|
||||
token instanceof Operator op
|
||||
&& ("Td".equals(op.getName())
|
||||
|| "TD".equals(op.getName())
|
||||
|| "Tm".equals(op.getName())))
|
||||
.count();
|
||||
|
||||
assertTrue(filteredPositioning > 0, "Positioning operators should be preserved");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle complex content streams with multiple operators")
|
||||
void shouldHandleComplexContentStreams() throws Exception {
|
||||
realPage = new PDPage(PDRectangle.A4);
|
||||
while (realDocument.getNumberOfPages() > 0) {
|
||||
realDocument.removePage(0);
|
||||
}
|
||||
realDocument.addPage(realPage);
|
||||
realPage.setResources(new PDResources());
|
||||
realPage.getResources()
|
||||
.put(
|
||||
COSName.getPDFName("F1"),
|
||||
new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(realDocument, realPage)) {
|
||||
contentStream.setLineWidth(2);
|
||||
contentStream.moveTo(100, 100);
|
||||
contentStream.lineTo(200, 200);
|
||||
contentStream.stroke();
|
||||
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(
|
||||
realPage.getResources().getFont(COSName.getPDFName("F1")), 12);
|
||||
contentStream.newLineAtOffset(50, 750);
|
||||
contentStream.showText("This is a complex document with ");
|
||||
contentStream.setTextRise(5);
|
||||
contentStream.showText("confidential");
|
||||
contentStream.setTextRise(0);
|
||||
contentStream.showText(" information.");
|
||||
contentStream.endText();
|
||||
|
||||
contentStream.addRect(300, 300, 100, 100);
|
||||
contentStream.fill();
|
||||
}
|
||||
|
||||
Set<String> targetWords = Set.of("confidential");
|
||||
|
||||
List<Object> tokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
assertNotNull(tokens);
|
||||
assertFalse(tokens.isEmpty());
|
||||
|
||||
String reconstructedText = extractTextFromTokens(tokens);
|
||||
assertFalse(
|
||||
reconstructedText.contains("confidential"), "Target text should be redacted");
|
||||
|
||||
boolean hasGraphicsOperators =
|
||||
tokens.stream()
|
||||
.anyMatch(
|
||||
token ->
|
||||
token instanceof Operator op
|
||||
&& ("re".equals(op.getName())
|
||||
|| "f".equals(op.getName())
|
||||
|| "m".equals(op.getName())
|
||||
|| "l".equals(op.getName())
|
||||
|| "S".equals(op.getName())));
|
||||
|
||||
assertTrue(hasGraphicsOperators, "Graphics operators should be preserved");
|
||||
boolean fallback =
|
||||
textRedactionService.performTextReplacement(
|
||||
realDocument, found, targetWords, false, false);
|
||||
assertFalse(fallback, "JPDFium text replacement should complete without fallback");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1423,14 +1117,13 @@ class RedactControllerTest {
|
||||
realDocument.addPage(realPage);
|
||||
|
||||
PDResources resources = new PDResources();
|
||||
resources.put(
|
||||
COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
resources.put(COSName.getPDFName("F1"), helvetica(realDocument));
|
||||
realPage.setResources(resources);
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(realDocument, realPage)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
contentStream.setFont(helvetica(realDocument), 12);
|
||||
contentStream.newLineAtOffset(50, 750);
|
||||
contentStream.showText("This is the first text block");
|
||||
contentStream.endText();
|
||||
@@ -1441,7 +1134,7 @@ class RedactControllerTest {
|
||||
contentStream.stroke();
|
||||
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
contentStream.setFont(helvetica(realDocument), 12);
|
||||
contentStream.newLineAtOffset(50, 650);
|
||||
contentStream.showText("This block contains confidential information");
|
||||
contentStream.endText();
|
||||
@@ -1450,7 +1143,7 @@ class RedactControllerTest {
|
||||
contentStream.fill();
|
||||
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
contentStream.setFont(helvetica(realDocument), 12);
|
||||
contentStream.newLineAtOffset(50, 550);
|
||||
contentStream.showText("This is the third text block");
|
||||
contentStream.endText();
|
||||
|
||||
@@ -10,6 +10,7 @@ import static org.mockito.Mockito.mock;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
@@ -22,6 +23,8 @@ 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.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
@@ -31,6 +34,7 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
@@ -118,18 +122,26 @@ class RedactExecuteServiceMoreTest {
|
||||
|
||||
private RedactExecuteRequest requestFor(byte[] pdfBytes) {
|
||||
RedactExecuteRequest req = new RedactExecuteRequest();
|
||||
req.setFileInput(
|
||||
new org.springframework.mock.web.MockMultipartFile(
|
||||
"fileInput", "in.pdf", "application/pdf", pdfBytes));
|
||||
req.setFileInput(new MockMultipartFile("fileInput", "in.pdf", "application/pdf", pdfBytes));
|
||||
return req;
|
||||
}
|
||||
|
||||
private PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private byte[] singlePageTextPdf(String... lines) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y - i * LINE_H);
|
||||
@@ -149,7 +161,7 @@ class RedactExecuteServiceMoreTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y);
|
||||
cs.showText("page " + p + " has SECRET content here");
|
||||
|
||||
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api.security;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -11,6 +12,8 @@ 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.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -219,12 +222,22 @@ class RedactExecuteServiceTest {
|
||||
* anchor) 1: line one 2: line two 3: line three 4: STOP-HERE (end anchor) 5: line five (must
|
||||
* NOT be redacted)
|
||||
*/
|
||||
private PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private PDDocument buildSingleColumnDoc() throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
String[] lines = {
|
||||
"START-HERE", "line one", "line two", "line three", "STOP-HERE", "line five"
|
||||
};
|
||||
@@ -247,7 +260,7 @@ class RedactExecuteServiceTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
// Body lines are padded to make each column genuinely wide enough that column
|
||||
// detection (which ignores narrow lines) treats both sides as real columns.
|
||||
String fill = " " + "x".repeat(26);
|
||||
@@ -294,7 +307,7 @@ class RedactExecuteServiceTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
String[] lines = {
|
||||
"#1 Auto layout",
|
||||
"Body about auto layout.",
|
||||
@@ -331,7 +344,7 @@ class RedactExecuteServiceTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
// Header — full width, lines 0..1.
|
||||
for (int i = 0; i < 2; i++) {
|
||||
cs.beginText();
|
||||
@@ -383,7 +396,7 @@ class RedactExecuteServiceTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
|
||||
float dateX = PAGE_WIDTH - 144f; // right-aligned dates near the right margin
|
||||
|
||||
|
||||
@@ -3,44 +3,24 @@ package stirling.software.SPDF.controller.api.security;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.contentstream.operator.Operator;
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSFloat;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdfparser.PDFStreamParser;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.common.PDStream;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
|
||||
/**
|
||||
* Further gap-coverage tests for {@link TextRedactionService}, complementing {@code
|
||||
* TextRedactionServiceTest} and {@code TextRedactionServiceMoreTest}. These target branches the
|
||||
* other two suites leave untouched: case-sensitive vs regex find, multi-term and multi-match within
|
||||
* one segment, the kerning ({@code adjustment != 0}) path that rewrites a {@code Tj} into a {@code
|
||||
* TJ} array, nested Form XObject traversal, pages with no resources, and the private width helpers
|
||||
* exercised directly via reflection.
|
||||
*/
|
||||
@DisplayName("TextRedactionService extra coverage")
|
||||
class TextRedactionServiceExtraTest {
|
||||
|
||||
@@ -50,43 +30,22 @@ class TextRedactionServiceExtraTest {
|
||||
|
||||
private final TextRedactionService service = new TextRedactionService();
|
||||
|
||||
private PDFont helvetica() {
|
||||
private PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private List<Object> parseTokens(PDPage page) throws IOException {
|
||||
PDFStreamParser parser = new PDFStreamParser(page);
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
Object t;
|
||||
while ((t = parser.parseNextToken()) != null) {
|
||||
tokens.add(t);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private String tokensText(List<Object> tokens) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Object token : tokens) {
|
||||
if (token instanceof COSString cs) {
|
||||
sb.append(cs.getString());
|
||||
} else if (token instanceof COSArray arr) {
|
||||
for (COSBase el : arr) {
|
||||
if (el instanceof COSString cs) {
|
||||
sb.append(cs.getString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Single page, one Tj line per supplied text line, Helvetica 12. */
|
||||
private PDDocument buildDoc(String... lines) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(helvetica(), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f);
|
||||
@@ -97,25 +56,6 @@ class TextRedactionServiceExtraTest {
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Page whose content stream is exactly {@code rawContent}, font F1=Helvetica. */
|
||||
private PDDocument docWithRawContent(String rawContent) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
PDResources resources = new PDResources();
|
||||
resources.put(COSName.getPDFName("F1"), helvetica());
|
||||
page.setResources(resources);
|
||||
|
||||
PDStream stream = new PDStream(doc);
|
||||
try (var out = stream.createOutputStream()) {
|
||||
out.write(rawContent.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(stream);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── findTextToRedact: matching modes ─────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("findTextToRedact matching modes")
|
||||
class FindModes {
|
||||
@@ -126,7 +66,6 @@ class TextRedactionServiceExtraTest {
|
||||
try (PDDocument doc = buildDoc("Secret and secret and SECRET")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"secret"}, false, false);
|
||||
// Patterns are compiled CASE_INSENSITIVE, so all three occurrences match.
|
||||
assertThat(result.get(0)).hasSize(3);
|
||||
}
|
||||
}
|
||||
@@ -148,7 +87,6 @@ class TextRedactionServiceExtraTest {
|
||||
try (PDDocument doc = buildDoc("abcde")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"[ae]"}, true, false);
|
||||
// 'a' and 'e' both match -> two single-character hits.
|
||||
assertThat(result.get(0)).hasSize(2);
|
||||
}
|
||||
}
|
||||
@@ -163,436 +101,4 @@ class TextRedactionServiceExtraTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── createTokensWithoutTargetText structural branches ────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createTokensWithoutTargetText structural branches")
|
||||
class TokenStructural {
|
||||
|
||||
@Test
|
||||
@DisplayName("page with null resources still parses and redacts the matched Tj text")
|
||||
void nullResourcesStillRedacts() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
// No resources set; the content stream references no real font.
|
||||
String raw = "BT 72 700 Td (SECRET) Tj ET";
|
||||
PDStream stream = new PDStream(doc);
|
||||
try (var out = stream.createOutputStream()) {
|
||||
out.write(raw.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(stream);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
assertThat(tokensText(tokens)).doesNotContain("SECRET");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a match inside a Tj segment is redacted and surrounding text survives")
|
||||
void multipleMatchesOneSegment() throws IOException {
|
||||
try (PDDocument doc = docWithRawContent("BT /F1 12 Tf 72 700 Td (xAAxAAx) Tj ET")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("AA"), false, false);
|
||||
String redacted = tokensText(tokens);
|
||||
// The segment was rewritten away from the original literal.
|
||||
assertThat(redacted).isNotEqualTo("xAAxAAx");
|
||||
// Redaction replaces matched runs with whitespace, so at least one "AA" is gone
|
||||
// (the leading occurrence) and the surrounding x characters survive.
|
||||
assertThat(redacted.split("AA", -1).length - 1).isLessThan(2);
|
||||
assertThat(redacted).startsWith("x ");
|
||||
assertThat(redacted).contains("x");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a second Tf operator updates the active font for later segments")
|
||||
void secondTfUpdatesFont() throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
PDResources resources = new PDResources();
|
||||
resources.put(COSName.getPDFName("F1"), helvetica());
|
||||
resources.put(
|
||||
COSName.getPDFName("F2"),
|
||||
new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN));
|
||||
page.setResources(resources);
|
||||
String raw = "BT /F1 12 Tf 72 700 Td (first) Tj /F2 18 Tf 0 -20 Td (SECRET) Tj ET";
|
||||
PDStream stream = new PDStream(doc);
|
||||
try (var out = stream.createOutputStream()) {
|
||||
out.write(raw.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(stream);
|
||||
try (doc) {
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
assertThat(tokensText(tokens)).doesNotContain("SECRET");
|
||||
assertThat(tokensText(tokens)).contains("first");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── nested Form XObject traversal ────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("nested Form XObject traversal")
|
||||
class NestedXObjects {
|
||||
|
||||
@Test
|
||||
@DisplayName("a match in a form nested two levels deep is reached and rewritten")
|
||||
void nestedTwoLevelsDeep() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
|
||||
// Inner form shows SECRET.
|
||||
PDFormXObject inner = new PDFormXObject(doc);
|
||||
inner.setResources(new PDResources());
|
||||
inner.getResources().put(COSName.getPDFName("F1"), helvetica());
|
||||
inner.setBBox(new PDRectangle(0, 0, 200, 50));
|
||||
try (var out = inner.getStream().createOutputStream()) {
|
||||
out.write(
|
||||
"BT /F1 12 Tf 0 10 Td (SECRET) Tj ET"
|
||||
.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
|
||||
// Outer form references inner via Do.
|
||||
PDFormXObject outer = new PDFormXObject(doc);
|
||||
PDResources outerRes = new PDResources();
|
||||
COSName innerName = outerRes.add(inner);
|
||||
outer.setResources(outerRes);
|
||||
outer.setBBox(new PDRectangle(0, 0, 200, 50));
|
||||
try (var out = outer.getStream().createOutputStream()) {
|
||||
out.write(
|
||||
("/" + innerName.getName() + " Do")
|
||||
.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
|
||||
PDResources pageRes = new PDResources();
|
||||
COSName outerName = pageRes.add(outer);
|
||||
page.setResources(pageRes);
|
||||
PDStream pageStream = new PDStream(doc);
|
||||
try (var out = pageStream.createOutputStream()) {
|
||||
out.write(
|
||||
("/" + outerName.getName() + " Do")
|
||||
.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(pageStream);
|
||||
|
||||
service.createTokensWithoutTargetText(doc, page, Set.of("SECRET"), false, false);
|
||||
|
||||
// The deep traversal must have rewritten the inner form's content stream.
|
||||
assertThat(inner.getCOSObject().containsKey(COSName.CONTENTS)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a form XObject with no resources is skipped without error")
|
||||
void formWithoutResourcesSkipped() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
|
||||
PDFormXObject form = new PDFormXObject(doc);
|
||||
form.setBBox(new PDRectangle(0, 0, 100, 50));
|
||||
// Intentionally no resources on the form.
|
||||
try (var out = form.getStream().createOutputStream()) {
|
||||
out.write("q Q".getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
|
||||
PDResources pageRes = new PDResources();
|
||||
COSName formName = pageRes.add(form);
|
||||
page.setResources(pageRes);
|
||||
PDStream pageStream = new PDStream(doc);
|
||||
try (var out = pageStream.createOutputStream()) {
|
||||
out.write(
|
||||
("/" + formName.getName() + " Do")
|
||||
.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(pageStream);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
assertThat(tokens).isNotNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── kerning / adjustment path in modifyTokenForRedaction ─────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("modifyTokenForRedaction adjustment branches")
|
||||
class ModifyTokenAdjustment {
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-zero width adjustment rewrites a Tj into a TJ array with kerning")
|
||||
void adjustmentRewritesToTjArray() throws Exception {
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
tokens.add(new COSString("KEEP"));
|
||||
tokens.add(Operator.getOperator("Tj"));
|
||||
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "Tj", "KEEP", 0, 4, helvetica(), FONT_SIZE);
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"modifyTokenForRedaction",
|
||||
List.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
String.class,
|
||||
float.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
// A clearly non-zero adjustment forces the COSArray + kerning branch.
|
||||
m.invoke(service, tokens, segment, "AB", 5.0f, List.of());
|
||||
|
||||
assertThat(tokens.get(0)).isInstanceOf(COSArray.class);
|
||||
COSArray arr = (COSArray) tokens.get(0);
|
||||
boolean hasKern = false;
|
||||
for (COSBase el : arr) {
|
||||
if (el instanceof COSFloat) {
|
||||
hasKern = true;
|
||||
}
|
||||
}
|
||||
assertThat(hasKern).as("kerning float should be appended to the TJ array").isTrue();
|
||||
// The trailing Tj operator should have been switched to TJ.
|
||||
assertThat(tokens.get(1)).isInstanceOf(Operator.class);
|
||||
assertThat(((Operator) tokens.get(1)).getName()).isEqualTo("TJ");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty replacement text with ~zero adjustment sets the shared empty COSString")
|
||||
void emptyReplacementZeroAdjustment() throws Exception {
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
tokens.add(new COSString("SECRET"));
|
||||
tokens.add(Operator.getOperator("Tj"));
|
||||
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "Tj", "SECRET", 0, 6, helvetica(), FONT_SIZE);
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"modifyTokenForRedaction",
|
||||
List.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
String.class,
|
||||
float.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(service, tokens, segment, "", 0f, List.of());
|
||||
|
||||
assertThat(tokens.get(0)).isInstanceOf(COSString.class);
|
||||
assertThat(((COSString) tokens.get(0)).getString()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the ' operator with a non-zero adjustment is also rewritten to a TJ array")
|
||||
void apostropheAdjustmentRewrites() throws Exception {
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
tokens.add(new COSString("WORD"));
|
||||
tokens.add(Operator.getOperator("'"));
|
||||
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "'", "WORD", 0, 4, helvetica(), FONT_SIZE);
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"modifyTokenForRedaction",
|
||||
List.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
String.class,
|
||||
float.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(service, tokens, segment, "X", 4.0f, List.of());
|
||||
|
||||
assertThat(tokens.get(0)).isInstanceOf(COSArray.class);
|
||||
assertThat(((Operator) tokens.get(1)).getName()).isEqualTo("TJ");
|
||||
}
|
||||
}
|
||||
|
||||
// ── createRedactedTJArray edge branches ──────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createRedactedTJArray edge branches")
|
||||
class RedactedTjArray {
|
||||
|
||||
@Test
|
||||
@DisplayName("non-COSString elements (kerning numbers) are preserved in order")
|
||||
void preservesNumberElements() throws Exception {
|
||||
COSArray original = new COSArray();
|
||||
original.add(new COSString("AA"));
|
||||
original.add(new COSFloat(-25f));
|
||||
original.add(new COSString("BB"));
|
||||
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "TJ", "AABB", 0, 4, helvetica(), FONT_SIZE);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
List.of(new TextRedactionService.MatchRange(0, 2)); // "AA"
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"createRedactedTJArray",
|
||||
COSArray.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
COSArray result = (COSArray) m.invoke(service, original, segment, matches);
|
||||
|
||||
boolean sawFloat = false;
|
||||
for (COSBase el : result) {
|
||||
if (el instanceof COSFloat) {
|
||||
sawFloat = true;
|
||||
}
|
||||
}
|
||||
assertThat(sawFloat).as("original kerning number must be retained").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a TJ array with no overlapping match is returned essentially unchanged")
|
||||
void noMatchLeavesTextIntact() throws Exception {
|
||||
COSArray original = new COSArray();
|
||||
original.add(new COSString("hello"));
|
||||
original.add(new COSString("world"));
|
||||
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "TJ", "helloworld", 0, 10, helvetica(), FONT_SIZE);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
List.of(new TextRedactionService.MatchRange(50, 60)); // out of range
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"createRedactedTJArray",
|
||||
COSArray.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
COSArray result = (COSArray) m.invoke(service, original, segment, matches);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (COSBase el : result) {
|
||||
if (el instanceof COSString cs) sb.append(cs.getString());
|
||||
}
|
||||
assertThat(sb.toString()).isEqualTo("helloworld");
|
||||
}
|
||||
}
|
||||
|
||||
// ── private width helpers via reflection ─────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("private width helpers via reflection")
|
||||
class WidthHelpers {
|
||||
|
||||
private float invokeFloat(String name, Object... args) throws Exception {
|
||||
Class<?>[] types = new Class<?>[] {PDFont.class, String.class};
|
||||
Method m = TextRedactionService.class.getDeclaredMethod(name, types);
|
||||
m.setAccessible(true);
|
||||
return (float) m.invoke(service, args);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("calculateConservativeWidth scales linearly at 500 units per character")
|
||||
void conservativeWidthLinear() throws Exception {
|
||||
float w = invokeFloat("calculateConservativeWidth", helvetica(), "abcd");
|
||||
assertThat(w).isEqualTo(4 * 500f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("calculateCharacterBasedWidth returns a positive width for normal text")
|
||||
void characterBasedWidthPositive() throws Exception {
|
||||
float w = invokeFloat("calculateCharacterBasedWidth", helvetica(), "Hello");
|
||||
assertThat(w).isGreaterThan(0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("calculateFallbackWidth returns a positive width using font metrics")
|
||||
void fallbackWidthPositive() throws Exception {
|
||||
float w = invokeFloat("calculateFallbackWidth", helvetica(), "Hello");
|
||||
assertThat(w).isGreaterThan(0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("safeGetStringWidth returns 0 for null/empty inputs")
|
||||
void safeWidthZeroForEmpty() throws Exception {
|
||||
assertThat(invokeFloat("safeGetStringWidth", helvetica(), "")).isZero();
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"safeGetStringWidth", PDFont.class, String.class);
|
||||
m.setAccessible(true);
|
||||
assertThat((float) m.invoke(service, helvetica(), null)).isZero();
|
||||
assertThat((float) m.invoke(service, (PDFont) null, "x")).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("safeGetStringWidth returns a positive width for a reliable font")
|
||||
void safeWidthPositive() throws Exception {
|
||||
float w = invokeFloat("safeGetStringWidth", helvetica(), "Word");
|
||||
assertThat(w).isGreaterThan(0f);
|
||||
}
|
||||
}
|
||||
|
||||
// ── createAlternativePlaceholder via reflection ──────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createAlternativePlaceholder via reflection")
|
||||
class AlternativePlaceholder {
|
||||
|
||||
@Test
|
||||
@DisplayName("Helvetica supports space, so output is a bounded run of spaces")
|
||||
void boundedSpaces() throws Exception {
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"createAlternativePlaceholder",
|
||||
String.class,
|
||||
float.class,
|
||||
PDFont.class,
|
||||
float.class);
|
||||
m.setAccessible(true);
|
||||
String result = (String) m.invoke(service, "hidden", 20f, helvetica(), FONT_SIZE);
|
||||
assertThat(result.chars().allMatch(c -> c == ' ')).isTrue();
|
||||
assertThat(result.length()).isLessThanOrEqualTo("hidden".length() * 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── extractTextSegments via reflection ───────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractTextSegments via reflection")
|
||||
class ExtractSegments {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test
|
||||
@DisplayName("a Tf operator sets font and size on the segments that follow it")
|
||||
void tfSetsFontAndSize() throws Exception {
|
||||
try (PDDocument doc = docWithRawContent("BT /F1 14 Tf 72 700 Td (hello) Tj ET")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> tokens = parseTokens(page);
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"extractTextSegments", PDPage.class, List.class);
|
||||
m.setAccessible(true);
|
||||
List<TextRedactionService.TextSegment> segments =
|
||||
(List<TextRedactionService.TextSegment>) m.invoke(service, page, tokens);
|
||||
|
||||
assertThat(segments).isNotEmpty();
|
||||
TextRedactionService.TextSegment first = segments.get(0);
|
||||
assertThat(first.getText()).isEqualTo("hello");
|
||||
assertThat(first.getFontSize()).isEqualTo(14f);
|
||||
assertThat(first.getFont()).isNotNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,40 +3,24 @@ package stirling.software.SPDF.controller.api.security;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSFloat;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdfparser.PDFStreamParser;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.common.PDStream;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
|
||||
/**
|
||||
* Gap-coverage tests for {@link TextRedactionService} targeting branches the existing {@code
|
||||
* TextRedactionServiceTest} does not reach: TJ-array redaction with kerning adjustment, the {@code
|
||||
* '} and {@code "} text-showing operators, Form XObject content rewriting, multi-page / multi-match
|
||||
* find+replace, and the private TJ/segment helpers exercised directly via reflection.
|
||||
*/
|
||||
@DisplayName("TextRedactionService additional coverage")
|
||||
class TextRedactionServiceMoreTest {
|
||||
|
||||
@@ -50,201 +34,16 @@ class TextRedactionServiceMoreTest {
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private List<Object> parseTokens(PDPage page) throws IOException {
|
||||
PDFStreamParser parser = new PDFStreamParser(page);
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
Object t;
|
||||
while ((t = parser.parseNextToken()) != null) {
|
||||
tokens.add(t);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private String tokensText(List<Object> tokens) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Object token : tokens) {
|
||||
if (token instanceof COSString cs) {
|
||||
sb.append(cs.getString());
|
||||
} else if (token instanceof COSArray arr) {
|
||||
for (COSBase el : arr) {
|
||||
if (el instanceof COSString cs) {
|
||||
sb.append(cs.getString());
|
||||
}
|
||||
}
|
||||
private PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
return helvetica();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a single page whose content stream is exactly {@code rawContent}, font F1=Helvetica.
|
||||
*/
|
||||
private PDDocument docWithRawContent(String rawContent) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
PDResources resources = new PDResources();
|
||||
resources.put(COSName.getPDFName("F1"), helvetica());
|
||||
page.setResources(resources);
|
||||
|
||||
PDStream stream = new PDStream(doc);
|
||||
try (var out = stream.createOutputStream()) {
|
||||
out.write(rawContent.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(stream);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── ' and " operators ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("apostrophe and quote text-showing operators")
|
||||
class MoveAndShowOperators {
|
||||
|
||||
@Test
|
||||
@DisplayName("the ' (move-to-next-line-and-show) operator gets its text redacted")
|
||||
void apostropheOperatorRedacted() throws IOException {
|
||||
// ' shows a string on the next line. Content: BT /F1 12 Tf 72 700 Td (PUBLIC) Tj
|
||||
// (SECRET) ' ET
|
||||
String raw = "BT /F1 12 Tf 72 700 Td (PUBLIC) Tj (SECRET) ' ET";
|
||||
try (PDDocument doc = docWithRawContent(raw)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
String text = tokensText(tokens);
|
||||
assertThat(text).doesNotContain("SECRET");
|
||||
assertThat(text).contains("PUBLIC");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the \" operator is collected as text-showing but its text is not extracted")
|
||||
void quoteOperatorNotExtracted() throws IOException {
|
||||
// " is in TEXT_SHOWING_OPERATORS, but extractTextFromToken's switch only handles
|
||||
// Tj/'/TJ, so a "-shown string yields no segment and survives. This pins that
|
||||
// behavior: the parse path runs without error and the token list is intact.
|
||||
String raw = "BT /F1 12 Tf 72 700 Td 1 2 (SECRET) \" ET";
|
||||
try (PDDocument doc = docWithRawContent(raw)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> before = parseTokens(page);
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
assertThat(tokens).hasSameSizeAs(before);
|
||||
assertThat(tokensText(tokens)).contains("SECRET");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TJ arrays with kerning ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("TJ positioning arrays")
|
||||
class TjArrays {
|
||||
|
||||
@Test
|
||||
@DisplayName("partial match inside a TJ array redacts only the matched run")
|
||||
void tjArrayPartialRedaction() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(helvetica(), FONT_SIZE);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y);
|
||||
// showTextWithPositioning emits a single TJ array.
|
||||
cs.showTextWithPositioning(
|
||||
new Object[] {"keep ", -50f, "SECRET", 20f, " tail"});
|
||||
cs.endText();
|
||||
}
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
|
||||
boolean sawTj = tokens.stream().anyMatch(t -> t instanceof COSArray);
|
||||
assertThat(sawTj).as("expected a TJ array token").isTrue();
|
||||
assertThat(tokensText(tokens)).doesNotContain("SECRET");
|
||||
assertThat(tokensText(tokens)).contains("keep");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("TJ array with no matching term is left unchanged")
|
||||
void tjArrayNoMatch() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(helvetica(), FONT_SIZE);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y);
|
||||
cs.showTextWithPositioning(new Object[] {"alpha ", -30f, "beta"});
|
||||
cs.endText();
|
||||
}
|
||||
List<Object> before = parseTokens(page);
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("ZZZ"), false, false);
|
||||
assertThat(tokens).hasSameSizeAs(before);
|
||||
assertThat(tokensText(tokens)).contains("alpha");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Form XObject traversal ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Form XObject content")
|
||||
class FormXObjects {
|
||||
|
||||
@Test
|
||||
@DisplayName("a referenced Form XObject containing a match is traversed and rewritten")
|
||||
void traversesFormXObject() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
|
||||
// Build a form XObject whose own content stream shows "SECRET".
|
||||
PDFormXObject form = new PDFormXObject(doc);
|
||||
form.setResources(new PDResources());
|
||||
form.getResources().put(COSName.getPDFName("F1"), helvetica());
|
||||
form.setBBox(new PDRectangle(0, 0, 200, 50));
|
||||
String formContent = "BT /F1 12 Tf 0 10 Td (SECRET) Tj ET";
|
||||
try (var out = form.getStream().createOutputStream()) {
|
||||
out.write(formContent.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
|
||||
PDResources pageResources = new PDResources();
|
||||
COSName formName = pageResources.add(form);
|
||||
page.setResources(pageResources);
|
||||
|
||||
String pageContent = "q 1 0 0 1 100 600 cm /" + formName.getName() + " Do Q";
|
||||
PDStream pageStream = new PDStream(doc);
|
||||
try (var out = pageStream.createOutputStream()) {
|
||||
out.write(pageContent.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(pageStream);
|
||||
|
||||
// Processing the page walks into the XObject graph; when a match is found inside
|
||||
// the
|
||||
// form, writeRedactedContentToXObject runs and sets a /Contents item on the form's
|
||||
// COS dictionary. Asserting that item appears proves the XObject redaction path
|
||||
// executed end-to-end without throwing.
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
|
||||
assertThat(tokens).isNotNull();
|
||||
assertThat(form.getCOSObject().containsKey(COSName.CONTENTS))
|
||||
.as("form XObject redaction path should have written a new content item")
|
||||
.isTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── multi-page / multi-match public entry points ─────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("findTextToRedact and performTextReplacement across pages")
|
||||
class MultiPage {
|
||||
@@ -256,7 +55,7 @@ class TextRedactionServiceMoreTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(helvetica(), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y);
|
||||
cs.showText(line);
|
||||
@@ -326,225 +125,32 @@ class TextRedactionServiceMoreTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ── private TJ / segment helpers via reflection ──────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("private helpers via reflection")
|
||||
class PrivateHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("createRedactedTJArray replaces the matched substring inside the array")
|
||||
void createRedactedTjArray() throws Exception {
|
||||
COSArray original = new COSArray();
|
||||
original.add(new COSString("SECRET"));
|
||||
original.add(new COSFloat(-40f));
|
||||
original.add(new COSString(" tail"));
|
||||
|
||||
// Segment text is the concatenation "SECRET tail"; startPos 0.
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "TJ", "SECRET tail", 0, 11, helvetica(), FONT_SIZE);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
List.of(new TextRedactionService.MatchRange(0, 6)); // "SECRET"
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"createRedactedTJArray",
|
||||
COSArray.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
COSArray result = (COSArray) m.invoke(service, original, segment, matches);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (COSBase el : result) {
|
||||
if (el instanceof COSString cs) sb.append(cs.getString());
|
||||
}
|
||||
assertThat(sb.toString()).doesNotContain("SECRET");
|
||||
assertThat(sb.toString()).contains("tail");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("applyRedactionsToSegmentText swaps the matched span for a placeholder")
|
||||
void applyRedactionsToSegmentText() throws Exception {
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "Tj", "keepSECRETkeep", 0, 14, helvetica(), FONT_SIZE);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
List.of(new TextRedactionService.MatchRange(4, 10)); // SECRET
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"applyRedactionsToSegmentText",
|
||||
TextRedactionService.TextSegment.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
String out = (String) m.invoke(service, segment, matches);
|
||||
assertThat(out).doesNotContain("SECRET");
|
||||
assertThat(out).startsWith("keep");
|
||||
assertThat(out).endsWith("keep");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("calculateWidthAdjustment returns 0 for a null-font segment")
|
||||
void widthAdjustmentNullFont() throws Exception {
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(0, "Tj", "abc", 0, 3, null, FONT_SIZE);
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"calculateWidthAdjustment",
|
||||
TextRedactionService.TextSegment.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
float adj =
|
||||
(float)
|
||||
m.invoke(
|
||||
service,
|
||||
segment,
|
||||
List.of(new TextRedactionService.MatchRange(0, 3)));
|
||||
assertThat(adj).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("calculateWidthAdjustment skips subset fonts (returns 0)")
|
||||
void widthAdjustmentSubsetFontSkipped() throws Exception {
|
||||
// A subset font name (6 uppercase letters + '+') trips the subset short-circuit.
|
||||
PDFont subsetNamed = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "Tj", "ABCDEF", 0, 6, subsetNamed, FONT_SIZE);
|
||||
|
||||
// The real Helvetica name is not a subset, so this segment goes through the normal
|
||||
// calculation; assert the call is at least exception-free and finite.
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"calculateWidthAdjustment",
|
||||
TextRedactionService.TextSegment.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
float adj =
|
||||
(float)
|
||||
m.invoke(
|
||||
service,
|
||||
segment,
|
||||
List.of(new TextRedactionService.MatchRange(0, 6)));
|
||||
assertThat(Float.isFinite(adj)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("modifyTokenForRedaction with an out-of-range token index is a no-op")
|
||||
void modifyTokenOutOfRange() throws Exception {
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
tokens.add(new COSString("hello"));
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
99, "Tj", "hello", 0, 5, helvetica(), FONT_SIZE);
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"modifyTokenForRedaction",
|
||||
List.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
String.class,
|
||||
float.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(service, tokens, segment, "", 0f, List.of());
|
||||
|
||||
// Token list is untouched because index 99 is out of bounds.
|
||||
assertThat(tokens).hasSize(1);
|
||||
assertThat(((COSString) tokens.get(0)).getString()).isEqualTo("hello");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("buildCompleteText concatenates the text of all segments in order")
|
||||
void buildCompleteText() throws Exception {
|
||||
List<TextRedactionService.TextSegment> segments =
|
||||
List.of(
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "Tj", "foo", 0, 3, helvetica(), FONT_SIZE),
|
||||
new TextRedactionService.TextSegment(
|
||||
1, "Tj", "bar", 3, 6, helvetica(), FONT_SIZE));
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod("buildCompleteText", List.class);
|
||||
m.setAccessible(true);
|
||||
assertThat(m.invoke(service, segments)).isEqualTo("foobar");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractTextFromToken returns text for the \" operator")
|
||||
void extractTextFromQuoteOperator() throws Exception {
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"extractTextFromToken", Object.class, String.class);
|
||||
m.setAccessible(true);
|
||||
// The " operator is not in the switch (Tj/'/TJ) -> default branch yields empty string.
|
||||
assertThat(m.invoke(service, new COSString("x"), "\"")).isEqualTo("");
|
||||
}
|
||||
}
|
||||
|
||||
// ── createPlaceholderWithWidth additional branches ───────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createPlaceholderWithWidth reliable-font path")
|
||||
class PlaceholderWidthBranches {
|
||||
|
||||
@Test
|
||||
@DisplayName("reliable font with positive width yields a bounded run of spaces")
|
||||
void reliableFontBoundedSpaces() {
|
||||
PDFont font = helvetica();
|
||||
String original = "Secret";
|
||||
float targetWidth;
|
||||
try {
|
||||
targetWidth = font.getStringWidth(original) / 1000f * FONT_SIZE;
|
||||
} catch (IOException e) {
|
||||
targetWidth = 30f;
|
||||
}
|
||||
String placeholder =
|
||||
service.createPlaceholderWithWidth(original, targetWidth, font, FONT_SIZE);
|
||||
assertThat(placeholder).isNotEmpty();
|
||||
assertThat(placeholder.chars().allMatch(c -> c == ' ')).isTrue();
|
||||
// spaceCount is capped at originalLength*2.
|
||||
assertThat(placeholder.length()).isLessThanOrEqualTo(original.length() * 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero target width falls back to alternative placeholder logic")
|
||||
void zeroTargetWidth() {
|
||||
PDFont font = helvetica();
|
||||
String placeholder = service.createPlaceholderWithWidth("word", 0f, font, FONT_SIZE);
|
||||
// With a reliable, non-subset font and zero width, output is still all whitespace.
|
||||
assertThat(placeholder.chars().allMatch(c -> c == ' ')).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// ── inner data classes ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("ModificationTask / GraphicsState data classes")
|
||||
@DisplayName("TextSegment / MatchRange data classes")
|
||||
class DataClasses {
|
||||
|
||||
@Test
|
||||
@DisplayName("GraphicsState defaults are null font and zero size, mutators round-trip")
|
||||
void graphicsStateRoundTrip() throws Exception {
|
||||
Class<?> gsClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.security.TextRedactionService$GraphicsState");
|
||||
var ctor = gsClass.getDeclaredConstructor();
|
||||
ctor.setAccessible(true);
|
||||
Object gs = ctor.newInstance();
|
||||
@DisplayName("TextSegment exposes its constructor values via accessors")
|
||||
void textSegmentAccessors() {
|
||||
PDFont font = helvetica();
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(3, "Tj", "hello", 10, 15, font, 12f);
|
||||
|
||||
Method getFont = gsClass.getDeclaredMethod("getFont");
|
||||
Method getSize = gsClass.getDeclaredMethod("getFontSize");
|
||||
getFont.setAccessible(true);
|
||||
getSize.setAccessible(true);
|
||||
assertThat(getFont.invoke(gs)).isNull();
|
||||
assertThat((float) getSize.invoke(gs)).isZero();
|
||||
assertThat(segment.getTokenIndex()).isEqualTo(3);
|
||||
assertThat(segment.getOperatorName()).isEqualTo("Tj");
|
||||
assertThat(segment.getText()).isEqualTo("hello");
|
||||
assertThat(segment.getStartPos()).isEqualTo(10);
|
||||
assertThat(segment.getEndPos()).isEqualTo(15);
|
||||
assertThat(segment.getFont()).isSameAs(font);
|
||||
assertThat(segment.getFontSize()).isEqualTo(12f);
|
||||
}
|
||||
|
||||
Method setSize = gsClass.getDeclaredMethod("setFontSize", float.class);
|
||||
setSize.setAccessible(true);
|
||||
setSize.invoke(gs, 14f);
|
||||
assertThat((float) getSize.invoke(gs)).isEqualTo(14f);
|
||||
@Test
|
||||
@DisplayName("MatchRange exposes start and end positions")
|
||||
void matchRangeAccessors() {
|
||||
TextRedactionService.MatchRange range = new TextRedactionService.MatchRange(4, 9);
|
||||
assertThat(range.getStartPos()).isEqualTo(4);
|
||||
assertThat(range.getEndPos()).isEqualTo(9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,30 +2,21 @@ package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
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.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.contentstream.operator.Operator;
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdfparser.PDFStreamParser;
|
||||
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.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -54,13 +45,23 @@ class TextRedactionServiceTest {
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return helvetica();
|
||||
}
|
||||
|
||||
/** Single page, single Tj line per supplied text line, Helvetica 12. */
|
||||
private PDDocument buildDoc(String... lines) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(helvetica(), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f);
|
||||
@@ -77,32 +78,6 @@ class TextRedactionServiceTest {
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── isTextShowingOperator ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("isTextShowingOperator")
|
||||
class IsTextShowingOperator {
|
||||
|
||||
@Test
|
||||
@DisplayName("recognises the four text-showing operators")
|
||||
void recognisesTextShowingOperators() {
|
||||
assertTrue(service.isTextShowingOperator("Tj"));
|
||||
assertTrue(service.isTextShowingOperator("TJ"));
|
||||
assertTrue(service.isTextShowingOperator("'"));
|
||||
assertTrue(service.isTextShowingOperator("\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects non text-showing operators and junk")
|
||||
void rejectsOthers() {
|
||||
assertFalse(service.isTextShowingOperator("BT"));
|
||||
assertFalse(service.isTextShowingOperator("ET"));
|
||||
assertFalse(service.isTextShowingOperator("Tf"));
|
||||
assertFalse(service.isTextShowingOperator(""));
|
||||
assertFalse(service.isTextShowingOperator("tj"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── findTextToRedact ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@@ -253,192 +228,6 @@ class TextRedactionServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ── detectCustomEncodingFonts ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("detectCustomEncodingFonts")
|
||||
class DetectCustomEncodingFonts {
|
||||
|
||||
@Test
|
||||
@DisplayName("standard Helvetica document is not flagged as custom-encoded")
|
||||
void standardFontNotFlagged() throws IOException {
|
||||
try (PDDocument doc = buildDoc("plain helvetica text")) {
|
||||
assertFalse(service.detectCustomEncodingFonts(doc));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("document with no content / no fonts is not flagged")
|
||||
void emptyDocumentNotFlagged() throws IOException {
|
||||
try (PDDocument doc = buildEmptyDoc()) {
|
||||
assertFalse(service.detectCustomEncodingFonts(doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── createPlaceholderWithFont ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createPlaceholderWithFont")
|
||||
class CreatePlaceholderWithFont {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for null")
|
||||
void nullReturnsNull() {
|
||||
assertNull(service.createPlaceholderWithFont(null, helvetica()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for empty string")
|
||||
void emptyReturnsEmpty() {
|
||||
assertEquals("", service.createPlaceholderWithFont("", helvetica()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-subset font yields spaces matching the original length")
|
||||
void nonSubsetFontYieldsMatchingSpaces() {
|
||||
String placeholder = service.createPlaceholderWithFont("hidden", helvetica());
|
||||
assertEquals(" ".repeat("hidden".length()), placeholder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null font is treated as non-subset and yields spaces")
|
||||
void nullFontYieldsSpaces() {
|
||||
String placeholder = service.createPlaceholderWithFont("abc", null);
|
||||
assertEquals(" ", placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
// ── createPlaceholderWithWidth ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createPlaceholderWithWidth")
|
||||
class CreatePlaceholderWithWidth {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for null")
|
||||
void nullReturnsNull() {
|
||||
assertNull(service.createPlaceholderWithWidth(null, 10f, helvetica(), FONT_SIZE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for empty string")
|
||||
void emptyReturnsEmpty() {
|
||||
assertEquals("", service.createPlaceholderWithWidth("", 10f, helvetica(), FONT_SIZE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null font falls back to one space per original character")
|
||||
void nullFontFallsBackToSpaces() {
|
||||
String placeholder = service.createPlaceholderWithWidth("word", 50f, null, FONT_SIZE);
|
||||
assertEquals(" ".repeat("word".length()), placeholder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-positive font size falls back to one space per original character")
|
||||
void nonPositiveFontSizeFallsBackToSpaces() {
|
||||
String placeholder = service.createPlaceholderWithWidth("word", 50f, helvetica(), 0f);
|
||||
assertEquals(" ".repeat("word".length()), placeholder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("standard font produces a non-null all-whitespace placeholder")
|
||||
void standardFontProducesWhitespacePlaceholder() {
|
||||
PDFont font = helvetica();
|
||||
float fontSize = FONT_SIZE;
|
||||
String original = "Secret";
|
||||
// Compute a realistic target width the way the service does (text-space / 1000 * size).
|
||||
float targetWidth;
|
||||
try {
|
||||
targetWidth = font.getStringWidth(original) / 1000f * fontSize;
|
||||
} catch (IOException e) {
|
||||
targetWidth = 30f;
|
||||
}
|
||||
|
||||
String placeholder =
|
||||
service.createPlaceholderWithWidth(original, targetWidth, font, fontSize);
|
||||
|
||||
assertNotNull(placeholder);
|
||||
assertFalse(placeholder.isEmpty(), "Helvetica supports spaces, so non-empty expected");
|
||||
assertTrue(
|
||||
placeholder.chars().allMatch(c -> c == ' '),
|
||||
"placeholder should be composed only of spaces");
|
||||
}
|
||||
}
|
||||
|
||||
// ── createTokensWithoutTargetText / writeFilteredContentStream
|
||||
// ────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createTokensWithoutTargetText")
|
||||
class CreateTokensWithoutTargetText {
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"returns a non-empty token list and preserves token count when nothing matches")
|
||||
void noMatchPreservesTokens() throws IOException {
|
||||
try (PDDocument doc = buildDoc("nothing to hide")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> originalTokens = parseTokens(page);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("ABSENT"), false, false);
|
||||
|
||||
assertNotNull(tokens);
|
||||
assertEquals(
|
||||
originalTokens.size(),
|
||||
tokens.size(),
|
||||
"token count should be unchanged when nothing matched");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filtered tokens can be written back and the page re-parses cleanly")
|
||||
void filteredTokensRoundTrip() throws IOException {
|
||||
try (PDDocument doc = buildDoc("redact SECRET token roundtrip")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
assertNotNull(tokens);
|
||||
|
||||
service.writeFilteredContentStream(doc, page, tokens);
|
||||
|
||||
// The page must still hold valid content (at least one operator token).
|
||||
List<Object> reparsed = parseTokens(page);
|
||||
boolean hasOperator = reparsed.stream().anyMatch(t -> t instanceof Operator);
|
||||
assertTrue(hasOperator, "rewritten content stream must contain operators");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty target-word set leaves tokens untouched")
|
||||
void emptyTargetSetLeavesTokens() throws IOException {
|
||||
try (PDDocument doc = buildDoc("some content")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> originalTokens = parseTokens(page);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Collections.emptySet(), false, false);
|
||||
|
||||
assertEquals(originalTokens.size(), tokens.size());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Object> parseTokens(PDPage page) throws IOException {
|
||||
PDFStreamParser parser = new PDFStreamParser(page);
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
Object token;
|
||||
while ((token = parser.parseNextToken()) != null) {
|
||||
tokens.add(token);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// ── inner data classes ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@@ -484,83 +273,4 @@ class TextRedactionServiceTest {
|
||||
assertFalse(a.equals(b));
|
||||
}
|
||||
}
|
||||
|
||||
// ── private logic exercised via reflection ───────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("findAllMatches / buildCompleteText (private logic via reflection)")
|
||||
class PrivateLogic {
|
||||
|
||||
@Test
|
||||
@DisplayName("findAllMatches returns sorted, non-overlapping match ranges for two terms")
|
||||
@SuppressWarnings("unchecked")
|
||||
void findAllMatchesSorted() throws Exception {
|
||||
String complete = "alpha beta gamma beta";
|
||||
Set<String> terms = new LinkedHashSet<>(List.of("beta", "alpha"));
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"findAllMatches",
|
||||
String.class,
|
||||
Set.class,
|
||||
boolean.class,
|
||||
boolean.class);
|
||||
m.setAccessible(true);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
(List<TextRedactionService.MatchRange>)
|
||||
m.invoke(service, complete, terms, false, false);
|
||||
|
||||
assertNotNull(matches);
|
||||
assertFalse(matches.isEmpty());
|
||||
// Results are sorted by start position.
|
||||
for (int i = 1; i < matches.size(); i++) {
|
||||
assertTrue(
|
||||
matches.get(i - 1).getStartPos() <= matches.get(i).getStartPos(),
|
||||
"matches must be sorted ascending by start position");
|
||||
}
|
||||
// "alpha" at 0, "beta" at 6 and 17 -> three matches total.
|
||||
assertEquals(3, matches.size());
|
||||
assertEquals(0, matches.get(0).getStartPos());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("findAllMatches returns nothing when no term occurs")
|
||||
@SuppressWarnings("unchecked")
|
||||
void findAllMatchesEmptyWhenAbsent() throws Exception {
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"findAllMatches",
|
||||
String.class,
|
||||
Set.class,
|
||||
boolean.class,
|
||||
boolean.class);
|
||||
m.setAccessible(true);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
(List<TextRedactionService.MatchRange>)
|
||||
m.invoke(service, "no terms here", Set.of("XYZ"), false, false);
|
||||
assertTrue(matches.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractTextFromToken pulls text from Tj COSString and TJ COSArray")
|
||||
void extractTextFromToken() throws Exception {
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"extractTextFromToken", Object.class, String.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
assertEquals("hi", m.invoke(service, new COSString("hi"), "Tj"));
|
||||
assertEquals("hi", m.invoke(service, new COSString("hi"), "'"));
|
||||
|
||||
COSArray tjArray = new COSArray();
|
||||
tjArray.add(new COSString("foo"));
|
||||
tjArray.add(new COSString("bar"));
|
||||
assertEquals("foobar", m.invoke(service, tjArray, "TJ"));
|
||||
|
||||
// Unknown operator yields empty string.
|
||||
assertEquals("", m.invoke(service, new COSString("x"), "Td"));
|
||||
// Wrong token type for the operator yields empty string.
|
||||
assertEquals("", m.invoke(service, new COSArray(), "Tj"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package stirling.software.SPDF.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
|
||||
/**
|
||||
* Lives in core because the python resources it extracts ship in this module.
|
||||
*
|
||||
* <p>Callers hand the returned path straight to python3, so the file has to stay readable while
|
||||
* other requests are extracting it. Re-writing it per call used to break that wherever rename is
|
||||
* not atomic, such as a 9p or NFS bind mount.
|
||||
*/
|
||||
class ExtractScriptConcurrencyTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("concurrent callers always get a readable script")
|
||||
void concurrentCallersSeeAReadableScript() throws Exception {
|
||||
int threads = 16;
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
CountDownLatch done = new CountDownLatch(threads);
|
||||
AtomicInteger unreadable = new AtomicInteger();
|
||||
AtomicInteger errors = new AtomicInteger();
|
||||
|
||||
for (int i = 0; i < threads; i++) {
|
||||
Thread.ofVirtual()
|
||||
.start(
|
||||
() -> {
|
||||
try {
|
||||
start.await();
|
||||
for (int n = 0; n < 25; n++) {
|
||||
Path script = GeneralUtils.extractScript("png_to_webp.py");
|
||||
if (!Files.isReadable(script)) {
|
||||
unreadable.incrementAndGet();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
errors.incrementAndGet();
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start.countDown();
|
||||
assertTrue(done.await(60, TimeUnit.SECONDS), "extractScript threads did not finish");
|
||||
assertEquals(0, errors.get(), "extractScript threw under concurrency");
|
||||
assertEquals(0, unreadable.get(), "script was missing while another caller held it");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("repeated calls return the same path without rewriting the file")
|
||||
void repeatedCallsAreStable() throws Exception {
|
||||
Path first = GeneralUtils.extractScript("png_to_webp.py");
|
||||
long modified = Files.getLastModifiedTime(first).toMillis();
|
||||
long size = Files.size(first);
|
||||
|
||||
// Any rewrite after this pause lands on a later timestamp, so mtime is a
|
||||
// reliable signal rather than a same-millisecond coin flip.
|
||||
Thread.sleep(50);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertEquals(first, GeneralUtils.extractScript("png_to_webp.py"));
|
||||
}
|
||||
|
||||
assertEquals(modified, Files.getLastModifiedTime(first).toMillis(), "script was rewritten");
|
||||
assertEquals(size, Files.size(first));
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
@@ -442,4 +444,52 @@ class JobControllerTest {
|
||||
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
|
||||
verify(fileStorage, never()).getFileSize(eq(fileId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCleanupFinishedJobs_ReportsWhatWasReleased() {
|
||||
when(taskManager.cleanupFinishedJobsNow(any()))
|
||||
.thenReturn(new TaskManager.CleanupSummary(2, 5, 1));
|
||||
|
||||
ResponseEntity<?> response = controller.cleanupFinishedJobs();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> body = (Map<String, Object>) response.getBody();
|
||||
assertEquals(2, body.get("jobsRemoved"));
|
||||
assertEquals(5, body.get("filesDeleted"));
|
||||
assertEquals(1, body.get("jobsRetained"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCleanupFinishedJobs_OnlySweepsJobsTheCallerOwns() {
|
||||
ReflectionTestUtils.setField(controller, "jobOwnershipService", jobOwnershipService);
|
||||
when(jobOwnershipService.validateJobAccess("me:job")).thenReturn(true);
|
||||
when(jobOwnershipService.validateJobAccess("someone-else:job"))
|
||||
.thenThrow(new SecurityException("not yours"));
|
||||
when(taskManager.cleanupFinishedJobsNow(any()))
|
||||
.thenReturn(new TaskManager.CleanupSummary(1, 1, 1));
|
||||
|
||||
controller.cleanupFinishedJobs();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Predicate<String>> filter = ArgumentCaptor.forClass(Predicate.class);
|
||||
verify(taskManager).cleanupFinishedJobsNow(filter.capture());
|
||||
assertTrue(filter.getValue().test("me:job"));
|
||||
assertFalse(
|
||||
filter.getValue().test("someone-else:job"),
|
||||
"A job the caller cannot access must be left in place");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCleanupFinishedJobs_SweepsEverythingWhenSecurityIsDisabled() {
|
||||
when(taskManager.cleanupFinishedJobsNow(any()))
|
||||
.thenReturn(new TaskManager.CleanupSummary(3, 3, 0));
|
||||
|
||||
controller.cleanupFinishedJobs();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Predicate<String>> filter = ArgumentCaptor.forClass(Predicate.class);
|
||||
verify(taskManager).cleanupFinishedJobsNow(filter.capture());
|
||||
assertTrue(filter.getValue().test("any-job-id"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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 io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -66,28 +67,40 @@ public class AdminJobController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger cleanup of old jobs (admin only)
|
||||
* Manually trigger cleanup of old jobs (admin only). Covers every user's jobs, unlike the
|
||||
* self-service {@code POST /api/v1/general/jobs/cleanup}, which only releases the caller's own.
|
||||
*
|
||||
* @return A response indicating how many jobs were cleaned up
|
||||
* @param force Ignore the retention window and release every finished job now, rather than only
|
||||
* those already past it
|
||||
* @return A response indicating how many jobs and files were cleaned up
|
||||
*/
|
||||
@PostMapping("/job/cleanup")
|
||||
@Operation(summary = "Cleanup old jobs")
|
||||
@Operation(
|
||||
summary = "Cleanup old jobs",
|
||||
description =
|
||||
"Runs the job retention sweep now across all users. With force=true the"
|
||||
+ " retention window is ignored and every finished job is released"
|
||||
+ " immediately.")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<?> cleanupOldJobs() {
|
||||
int beforeCount = taskManager.getJobStats().getTotalJobs();
|
||||
taskManager.cleanupOldJobs();
|
||||
int afterCount = taskManager.getJobStats().getTotalJobs();
|
||||
int removedCount = beforeCount - afterCount;
|
||||
public ResponseEntity<?> cleanupOldJobs(
|
||||
@RequestParam(name = "force", defaultValue = "false") boolean force) {
|
||||
TaskManager.CleanupSummary summary =
|
||||
force
|
||||
? taskManager.cleanupFinishedJobsNow(jobId -> true)
|
||||
: taskManager.cleanupOldJobs();
|
||||
|
||||
log.info(
|
||||
"Admin triggered job cleanup: removed {} jobs, {} remaining",
|
||||
removedCount,
|
||||
afterCount);
|
||||
"Admin triggered job cleanup (force={}): removed {} jobs and {} files, {} remaining",
|
||||
force,
|
||||
summary.jobsRemoved(),
|
||||
summary.filesDeleted(),
|
||||
summary.jobsRetained());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message", "Cleanup complete",
|
||||
"removedJobs", removedCount,
|
||||
"remainingJobs", afterCount));
|
||||
"removedJobs", summary.jobsRemoved(),
|
||||
"filesDeleted", summary.filesDeleted(),
|
||||
"remainingJobs", summary.jobsRetained()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A failure a user hit in the editor, reported by their own client. The editor calls tools directly
|
||||
* rather than through the policy engine, so nothing server-side sees these unless the client says
|
||||
* so.
|
||||
*
|
||||
* <p>Note what the client cannot supply: no team, no actor, no document name. The first two come
|
||||
* from the authenticated session, and the third is never stored.
|
||||
*
|
||||
* @param operation the tool that failed, e.g. {@code remove-password}
|
||||
* @param errorCode the code from the tool's Problem Details response, or null when there was none
|
||||
* @param fileIds opaque client-side ids of the documents involved; empty when none is attributable
|
||||
* @param detail the message the user saw
|
||||
*/
|
||||
public record EditorFailureReport(
|
||||
String operation, String errorCode, List<String> fileIds, String detail) {
|
||||
|
||||
/**
|
||||
* Cap on the files one report may name. Each one becomes a permanent incident and this endpoint
|
||||
* is open to any authenticated user, so without a bound a single call can flood a leader's
|
||||
* queue. 200 is several times the largest batch an editor session plausibly fails on, and the
|
||||
* most the review queue shows in one page, so a real report never meets it.
|
||||
*
|
||||
* <p>An oversized report is refused whole rather than trimmed: see {@link
|
||||
* FileRunEventController#report}.
|
||||
*/
|
||||
static final int MAX_FILE_IDS = 200;
|
||||
|
||||
public EditorFailureReport {
|
||||
fileIds = fileIds == null ? List.of() : List.copyOf(fileIds);
|
||||
}
|
||||
|
||||
boolean hasOperation() {
|
||||
return operation != null && !operation.isBlank();
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted before the blank ids are dropped, because this bounds the request rather than the
|
||||
* rows it would produce.
|
||||
*/
|
||||
boolean namesTooManyFiles() {
|
||||
return fileIds.size() > MAX_FILE_IDS;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import lombok.Getter;
|
||||
* <p>Actions are declared here but implemented in {@link FailureAction} beans resolved by id, so a
|
||||
* new kind ships as a registry entry plus copy. Two members today: {@link #UNKNOWN} gives every
|
||||
* failed run a record, and kinds get promoted out of it as production shows what occurs.
|
||||
*
|
||||
* <p>A kind offers an acknowledgement only where there is something to acknowledge <em>doing</em>.
|
||||
* With nothing to fix, "seen it" and "clear it" are the same decision, so the row offers only the
|
||||
* one that clears it.
|
||||
*/
|
||||
@Getter
|
||||
public enum FailureKind {
|
||||
@@ -43,7 +47,6 @@ public enum FailureKind {
|
||||
FailureScope.RUN,
|
||||
noErrorCodes(),
|
||||
fallback("This run failed for a reason Stirling does not yet recognise."),
|
||||
offer(ACKNOWLEDGE),
|
||||
offer(DISMISS));
|
||||
|
||||
private static final String KEY_PREFIX = "portal.failures.kind.";
|
||||
|
||||
@@ -20,6 +20,7 @@ public record FileRunEvent(
|
||||
FailureOrigin origin,
|
||||
String policyId,
|
||||
String runId,
|
||||
String sourceId,
|
||||
String fileId,
|
||||
String detail,
|
||||
String dedupKey,
|
||||
@@ -46,6 +47,7 @@ public record FileRunEvent(
|
||||
entity.getOrigin(),
|
||||
entity.getPolicyId(),
|
||||
entity.getRunId(),
|
||||
entity.getSourceId(),
|
||||
entity.getFileId(),
|
||||
entity.getDetail(),
|
||||
entity.getDedupKey(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -21,15 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Read and triage recorded failures for the caller's team. Note the absence of a team parameter:
|
||||
* the team comes from the authenticated principal, never the request.
|
||||
* Read and triage recorded failures. Note the absence of a team parameter: the team comes from the
|
||||
* authenticated principal, never the request.
|
||||
*
|
||||
* <p>Reviewing failures is a leader-level capability, gated the same way policy editing is: see
|
||||
* {@link #requireFailureReviewAllowed()}.
|
||||
* <p>Every endpoint is open to any authenticated user and scoped in the service instead: a leader
|
||||
* reads and closes the whole team's failures, everyone else their own. Nothing here decides who may
|
||||
* do what, so the two cannot drift apart.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@@ -45,21 +44,21 @@ public class FileRunEventController {
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
|
||||
private final FileRunEventService service;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "List recorded failures",
|
||||
description =
|
||||
"Failures recorded for the caller's team, newest first. Each row carries its"
|
||||
+ " available actions already resolved.")
|
||||
"Failures the caller may see, newest first: their team's for a leader, their own"
|
||||
+ " for everyone else. Each row carries its available actions already"
|
||||
+ " resolved.")
|
||||
public FileRunEventsResponse list(
|
||||
// Spring's converter 400s on a value outside the enum, so no hand-rolled parse.
|
||||
@RequestParam(required = false) FileRunEventStatus status,
|
||||
@RequestParam(required = false) String kindId,
|
||||
@RequestParam(required = false) Integer limit) {
|
||||
requireFailureReviewAllowed();
|
||||
// No role gate: the service scopes the read instead, so a member gets their own failures
|
||||
// and a leader the team's.
|
||||
int cappedLimit = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT);
|
||||
|
||||
List<FileRunEventView> events =
|
||||
@@ -81,7 +80,8 @@ public class FileRunEventController {
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String actionId,
|
||||
@RequestBody(required = false) ActionRequest request) {
|
||||
requireFailureReviewAllowed();
|
||||
// No role gate: the service decides, which lets someone close their own failure while
|
||||
// still keeping a colleague's out of reach.
|
||||
Map<String, String> inputs = request == null ? Map.of() : request.safeInputs();
|
||||
try {
|
||||
FileRunEvent updated = service.dispatch(eventId, actionId, inputs);
|
||||
@@ -91,6 +91,50 @@ public class FileRunEventController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/reports")
|
||||
@Operation(
|
||||
summary = "Report a failure hit in the editor",
|
||||
description =
|
||||
"For failures the server never sees, because the editor calls tools directly."
|
||||
+ " Open to any authenticated user: whoever's work failed can say so, and"
|
||||
+ " reads it back scoped to themselves. Rejected with 400 if it names"
|
||||
+ " more files than one report may carry.")
|
||||
public ResponseEntity<Void> report(@RequestBody EditorFailureReport report) {
|
||||
if (report == null || !report.hasOperation()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "operation is required to report a failure");
|
||||
}
|
||||
// Refused whole rather than trimmed, and refused before the first write, so an oversized
|
||||
// report leaves no rows at all. Trimming would hand a reviewer part of a set with nothing
|
||||
// saying the rest existed, which is what the cap inside the service used to do. The limit
|
||||
// is stated in the message because the editor reports in the background: a client author
|
||||
// reading a log is the only person who will ever see this.
|
||||
if (report.namesTooManyFiles()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"a report may name at most "
|
||||
+ EditorFailureReport.MAX_FILE_IDS
|
||||
+ " files, and this one named "
|
||||
+ report.fileIds().size());
|
||||
}
|
||||
service.report(report);
|
||||
// No body: the editor reports and moves on, and has nothing to do with the row.
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/removed-files")
|
||||
@Operation(
|
||||
summary = "Close the incidents about files deleted from the editor",
|
||||
description =
|
||||
"Deleting the document leaves nothing to act on, so its incidents drop out of"
|
||||
+ " the queue while the rows stay for audit. Applies only to the"
|
||||
+ " caller's own editor rows, however senior they are.")
|
||||
public ResponseEntity<Void> filesRemoved(@RequestBody(required = false) RemovedFiles request) {
|
||||
service.forgetFiles(request == null ? List.of() : request.safeFileIds());
|
||||
// No body: the editor is telling the server, not asking it anything.
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/kinds")
|
||||
@Operation(
|
||||
summary = "List known failure kinds",
|
||||
@@ -98,29 +142,11 @@ public class FileRunEventController {
|
||||
"The failure registry. Lets a client describe kinds it was not built with, and"
|
||||
+ " doubles as the probe for whether failure tracking exists at all.")
|
||||
public List<FailureKindView> kinds() {
|
||||
requireFailureReviewAllowed();
|
||||
// The registry is copy and metadata, not anyone's data, and a member needs it to render the
|
||||
// failures they can already see.
|
||||
return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triage is for a team leader (SaaS) or admin (self-hosted), mirroring {@code
|
||||
* PolicyController.requirePolicyEditingAllowed()} rather than inventing a second notion of who
|
||||
* manages a team's automation: a member can trigger runs, a leader reviews them.
|
||||
*
|
||||
* <p>Login disabled means a single-user deployment with no roles to tell apart, the same
|
||||
* carve-out the policy endpoints make. Team scoping is separate, and lives in the service.
|
||||
*/
|
||||
private void requireFailureReviewAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return;
|
||||
}
|
||||
if (!policyManagementAuthority.canEditPolicies()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Recorded failures may only be reviewed by a team leader");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A closed row is a conflict rather than a bad request: the request was well-formed and would
|
||||
* have been valid a moment earlier.
|
||||
@@ -136,6 +162,22 @@ public class FileRunEventController {
|
||||
/** Wrapped rather than a bare array so pagination can be added without breaking clients. */
|
||||
public record FileRunEventsResponse(List<FileRunEventView> events) {}
|
||||
|
||||
/**
|
||||
* Files gone from the caller's editor. Opaque ids only, as everywhere else on this API.
|
||||
*
|
||||
* <p>Deliberately uncapped where a report is capped, because this creates nothing: it closes
|
||||
* rows the caller already owns, so however long the list is, it can only ever touch incidents
|
||||
* that already exist. Refusing an oversized one would also be the harmful direction here, since
|
||||
* the editor says this once and never retries: those incidents would sit in the queue asking
|
||||
* for attention about files that no longer exist.
|
||||
*/
|
||||
public record RemovedFiles(List<String> fileIds) {
|
||||
|
||||
List<String> safeFileIds() {
|
||||
return fileIds == null ? List.of() : fileIds;
|
||||
}
|
||||
}
|
||||
|
||||
/** Inputs an action declared it needs. Empty for both actions that exist today. */
|
||||
public record ActionRequest(Map<String, String> inputs) {
|
||||
|
||||
|
||||
@@ -84,6 +84,10 @@ public class FileRunEventEntity implements Serializable {
|
||||
@Column(name = "run_id")
|
||||
private String runId;
|
||||
|
||||
/** Which folder, bucket or webhook fed the run. Null when a user supplied the file. */
|
||||
@Column(name = "source_id")
|
||||
private String sourceId;
|
||||
|
||||
@Column(name = "file_id")
|
||||
private String fileId;
|
||||
|
||||
|
||||
@@ -17,27 +17,36 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity, String> {
|
||||
|
||||
/**
|
||||
* This team's events, newest first, scoped in the query rather than loaded and filtered. A
|
||||
* {@code null} teamId matches the rows with no team (login disabled), mirroring {@link
|
||||
* stirling.software.proprietary.policy.source.SourceRepository#findByTeam}, since a plain
|
||||
* {@code = null} would return nothing.
|
||||
* As {@link #findByTeamAndStatus} but for a set of statuses, e.g. the open ones. The kind
|
||||
* filter is in the query, before the limit: filtering an already-limited page could return
|
||||
* nothing while matching rows exist.
|
||||
*
|
||||
* <p>{@code actor} narrows to one person's own failures. Null means the whole team, which only
|
||||
* a leader ever asks for: see {@code FileRunEventService#readScope}.
|
||||
*/
|
||||
@Query(
|
||||
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
|
||||
+ " e.teamId = :teamId) and (:kindId is null or e.kindId = :kindId)"
|
||||
+ " order by e.lastSeenAt desc")
|
||||
List<FileRunEventEntity> findByTeam(
|
||||
@Param("teamId") Long teamId, @Param("kindId") String kindId, Pageable pageable);
|
||||
+ " e.teamId = :teamId) and e.status in :statuses"
|
||||
+ " and (:kindId is null or e.kindId = :kindId)"
|
||||
+ " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc")
|
||||
List<FileRunEventEntity> findByTeamAndStatusIn(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("statuses") List<FileRunEventStatus> statuses,
|
||||
@Param("kindId") String kindId,
|
||||
@Param("actor") String actor,
|
||||
Pageable pageable);
|
||||
|
||||
/** As {@link #findByTeam} but restricted to one status, for the review surface's filters. */
|
||||
/** As {@link #findByTeamAndStatusIn} but for exactly one status, for the surface's filters. */
|
||||
@Query(
|
||||
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
|
||||
+ " e.teamId = :teamId) and e.status = :status"
|
||||
+ " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc")
|
||||
+ " and (:kindId is null or e.kindId = :kindId)"
|
||||
+ " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc")
|
||||
List<FileRunEventEntity> findByTeamAndStatus(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("status") FileRunEventStatus status,
|
||||
@Param("kindId") String kindId,
|
||||
@Param("actor") String actor,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
@@ -85,6 +94,31 @@ public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity
|
||||
@Param("now") Instant now,
|
||||
@Param("allowedFrom") Collection<FileRunEventStatus> allowedFrom);
|
||||
|
||||
/**
|
||||
* Close the incidents about documents their owner deleted from the editor: the queue is what
|
||||
* needs attention, and a document that no longer exists needs none.
|
||||
*
|
||||
* <p>Restricted to that owner's own editor rows. File ids are minted by the client, so scoping
|
||||
* on team alone would let one caller close a colleague's incidents by naming ids. Processor
|
||||
* rows are excluded outright: nothing was deleted from an editor there.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true)
|
||||
@Transactional
|
||||
@Query(
|
||||
"update FileRunEventEntity e set e.status ="
|
||||
+ " stirling.software.proprietary.failure.FileRunEventStatus.FILE_REMOVED,"
|
||||
+ " e.statusActor = :actor, e.statusAt = :now where e.origin ="
|
||||
+ " stirling.software.proprietary.failure.FailureOrigin.TOOL and ((:teamId is"
|
||||
+ " null and e.teamId is null) or e.teamId = :teamId) and ((:actor is null and"
|
||||
+ " e.actor is null) or e.actor = :actor) and e.fileId in :fileIds and e.status in"
|
||||
+ " :allowedFrom")
|
||||
int markFilesRemoved(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("actor") String actor,
|
||||
@Param("fileIds") Collection<String> fileIds,
|
||||
@Param("now") Instant now,
|
||||
@Param("allowedFrom") Collection<FileRunEventStatus> allowedFrom);
|
||||
|
||||
/**
|
||||
* The most recent row for this exact failure, so the rollup can increment an existing incident
|
||||
* instead of opening a new one. Team-scoped, so the same failure in two teams stays two rows.
|
||||
|
||||
@@ -13,12 +13,14 @@ import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Reads and acts on incidents for the calling user's team.
|
||||
* Reads and acts on the incidents the calling user is allowed to see, which is where that decision
|
||||
* is made: a leader reads and closes the whole team's failures, everyone else their own. Keeping it
|
||||
* here rather than on the endpoints means the read and the triage cannot drift apart.
|
||||
*
|
||||
* <p>Team scoping mirrors {@code PolicyAccessGuard}: everyone sees only their own team's rows, the
|
||||
* team always comes from the authenticated principal, and scoping applies only when login is
|
||||
* enabled so single-user deployments keep working. When the team cannot be resolved the caller
|
||||
* reads nothing; see {@link #scope()}.
|
||||
* reads nothing; see {@link #readScope()}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -31,13 +33,83 @@ public class FileRunEventService {
|
||||
private final UserServiceInterface userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/** The calling user's events, newest first. Empty when their team cannot be resolved. */
|
||||
/**
|
||||
* Record a failure a user hit in the editor. One incident per named file, so each document
|
||||
* stays separately actionable; one unattributed incident when the report names none.
|
||||
*
|
||||
* <p>The team and actor come from the session rather than the report, and the kind is
|
||||
* classified from the reported code, falling back to {@link FailureKind#UNKNOWN} for a code no
|
||||
* kind claims.
|
||||
*/
|
||||
public List<FileRunEvent> report(EditorFailureReport report) {
|
||||
FailureKind kind = FailureKind.byErrorCode(report.errorCode()).orElse(FailureKind.UNKNOWN);
|
||||
// The caller's team, not their read scope: recording is open to everyone, and a reader who
|
||||
// may see nothing still has their failure filed under the team it happened in.
|
||||
Long teamId = currentTeamId();
|
||||
String actor = currentActor();
|
||||
String detail = detailFor(report);
|
||||
|
||||
List<String> fileIds =
|
||||
report.fileIds().stream().filter(id -> id != null && !id.isBlank()).toList();
|
||||
if (fileIds.isEmpty()) {
|
||||
return List.of(recordReported(kind, teamId, actor, null, detail));
|
||||
}
|
||||
// Every named file gets its row. An earlier cap here silently dropped the rest, which lost
|
||||
// failures a reviewer needed and was inconsistent with the processor path, where a sweep
|
||||
// records one row per failing file with no limit at all. How many files one report may name
|
||||
// is bounded at the boundary instead (see EditorFailureReport#MAX_FILE_IDS), where an
|
||||
// oversized report can be refused whole before anything is written.
|
||||
return fileIds.stream()
|
||||
.map(fileId -> recordReported(kind, teamId, actor, fileId, detail))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private FileRunEvent recordReported(
|
||||
FailureKind kind, Long teamId, String actor, String fileId, String detail) {
|
||||
return store.record(RecordFailure.forEditor(kind, teamId, actor, fileId, detail));
|
||||
}
|
||||
|
||||
/**
|
||||
* The operation is the context a reviewer needs, since an editor failure has no policy or run.
|
||||
*/
|
||||
private String detailFor(EditorFailureReport report) {
|
||||
String message = report.detail() == null ? "" : report.detail();
|
||||
return message.isBlank() ? report.operation() : report.operation() + ": " + message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the incidents about documents the caller has deleted from their editor. The queue means
|
||||
* "needs attention", and a document that no longer exists needs none; the rows stay for audit.
|
||||
*
|
||||
* <p>Best-effort by nature: this only arrives if the browser that owns the file says so, and a
|
||||
* cleared cache or another device never will. Rows left open that way are retention's problem,
|
||||
* not this method's.
|
||||
*
|
||||
* <p>Narrowed to the caller's own rows however senior they are, which is why it passes {@link
|
||||
* #currentActor()} rather than the read scope's actor: file ids are minted by each client, so a
|
||||
* leader reading with a null actor would match every unattributed row in the team.
|
||||
*
|
||||
* @return how many incidents were closed
|
||||
*/
|
||||
public int forgetFiles(List<String> fileIds) {
|
||||
ReadScope scope = readScope();
|
||||
if (!scope.permitted()) {
|
||||
return 0;
|
||||
}
|
||||
List<String> named = fileIds.stream().filter(id -> id != null && !id.isBlank()).toList();
|
||||
return store.markFilesRemoved(scope.teamId(), currentActor(), named);
|
||||
}
|
||||
|
||||
/**
|
||||
* The events the caller may read, newest first: the team's for a leader, their own for everyone
|
||||
* else. Empty when their team cannot be resolved.
|
||||
*/
|
||||
public List<FileRunEvent> list(FileRunEventStatus status, String kindId, int limit) {
|
||||
TeamScope scope = scope();
|
||||
ReadScope scope = readScope();
|
||||
if (!scope.permitted()) {
|
||||
return List.of();
|
||||
}
|
||||
return store.list(scope.teamId(), status, kindId, limit);
|
||||
return store.list(scope.teamId(), status, kindId, scope.actor(), limit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +119,13 @@ public class FileRunEventService {
|
||||
* event's kind does not declare the action, or the event is already closed
|
||||
*/
|
||||
public FileRunEvent dispatch(String eventId, String actionId, Map<String, String> inputs) {
|
||||
TeamScope scope = scope();
|
||||
// Whoever can see it can close it: a leader for the whole team, everyone else for the
|
||||
// failures they caused. Someone who fixes their own problem should not have to ask a leader
|
||||
// to clear the row.
|
||||
//
|
||||
// Closing the row is all this covers. Acting on the document behind it, such as supplying a
|
||||
// password for a retry, would need its own permission, and no such action exists yet.
|
||||
ReadScope scope = readScope();
|
||||
if (!scope.permitted()) {
|
||||
// Reported as "no such event", the same as an id from another team, so the response
|
||||
// does
|
||||
@@ -57,6 +135,12 @@ public class FileRunEventService {
|
||||
}
|
||||
FileRunEvent event =
|
||||
store.find(eventId, scope.teamId())
|
||||
// Reported as "no such event" rather than a refusal, so a member cannot
|
||||
// learn that a colleague's incident exists by trying to close it.
|
||||
.filter(
|
||||
found ->
|
||||
scope.actor() == null
|
||||
|| scope.actor().equals(found.actor()))
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new FailureActionException(
|
||||
@@ -118,31 +202,55 @@ public class FileRunEventService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Which rows the caller may touch, since a null team id means two different things. Login
|
||||
* disabled is the self-hosted setup with no users or teams, where unteamed rows are everyone's,
|
||||
* as {@code PolicyAccessGuard} also treats them. Login enabled with no resolvable team reads
|
||||
* nothing, because unteamed rows there are shared by every team's ad-hoc runs.
|
||||
* Which rows the caller may read. A leader reviews the whole team's, as before. Everyone else
|
||||
* reads the failures they caused themselves: a member can already report one, so letting them
|
||||
* see their own back is what makes telling them about it worth anything, and it exposes nothing
|
||||
* of a colleague's.
|
||||
*
|
||||
* <p>A null team id means two different things. Login disabled is the self-hosted setup with no
|
||||
* users or teams, where unteamed rows are everyone's, as {@code PolicyAccessGuard} also treats
|
||||
* them. Login enabled with no resolvable team reads nothing, because unteamed rows there are
|
||||
* shared by every team's ad-hoc runs.
|
||||
*/
|
||||
private TeamScope scope() {
|
||||
private ReadScope readScope() {
|
||||
if (!enforced()) {
|
||||
return TeamScope.of(null);
|
||||
return ReadScope.wholeTeam(null);
|
||||
}
|
||||
Long teamId = policyManagementAuthority.currentUserTeamId();
|
||||
return teamId == null ? TeamScope.denied() : TeamScope.of(teamId);
|
||||
Long teamId = currentTeamId();
|
||||
if (teamId == null) {
|
||||
return ReadScope.denied();
|
||||
}
|
||||
if (policyManagementAuthority.canEditPolicies()) {
|
||||
return ReadScope.wholeTeam(teamId);
|
||||
}
|
||||
// Narrowing to "mine" needs a name to narrow by. Without one the filter would be dropped
|
||||
// and a member would read the whole team, so refuse rather than widen.
|
||||
String actor = currentActor();
|
||||
return actor == null ? ReadScope.denied() : ReadScope.mine(teamId, actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's readable team, or a refusal. {@code teamId} is only meaningful when permitted.
|
||||
* What the caller may read. {@code actor} is the person to narrow to, or null for the whole
|
||||
* team; both are only meaningful when permitted.
|
||||
*/
|
||||
private record TeamScope(boolean permitted, Long teamId) {
|
||||
private record ReadScope(boolean permitted, Long teamId, String actor) {
|
||||
|
||||
static TeamScope of(Long teamId) {
|
||||
return new TeamScope(true, teamId);
|
||||
static ReadScope wholeTeam(Long teamId) {
|
||||
return new ReadScope(true, teamId, null);
|
||||
}
|
||||
|
||||
static TeamScope denied() {
|
||||
return new TeamScope(false, null);
|
||||
static ReadScope mine(Long teamId, String actor) {
|
||||
return new ReadScope(true, teamId, actor);
|
||||
}
|
||||
|
||||
static ReadScope denied() {
|
||||
return new ReadScope(false, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** The team a row belongs to, which is nobody's when there are no teams to belong to. */
|
||||
private Long currentTeamId() {
|
||||
return enforced() ? policyManagementAuthority.currentUserTeamId() : null;
|
||||
}
|
||||
|
||||
private String currentActor() {
|
||||
|
||||
@@ -11,7 +11,14 @@ public enum FileRunEventStatus {
|
||||
NEW(false),
|
||||
ACKNOWLEDGED(false),
|
||||
DISMISSED(true),
|
||||
RESOLVED(true);
|
||||
RESOLVED(true),
|
||||
|
||||
/**
|
||||
* The document this incident was about was deleted from its owner's editor, so there is nothing
|
||||
* left to act on. Distinct from {@code DISMISSED}, which is a reviewer's decision, and from
|
||||
* {@code RESOLVED}, which reopens on recurrence: this one cannot recur, the file is gone.
|
||||
*/
|
||||
FILE_REMOVED(true);
|
||||
|
||||
/** The statuses a review queue shows by default: everything still needing a decision. */
|
||||
private static final List<FileRunEventStatus> OPEN =
|
||||
|
||||
@@ -98,6 +98,7 @@ public class FileRunEventStore {
|
||||
entity.setOrigin(command.origin());
|
||||
entity.setPolicyId(command.policyId());
|
||||
entity.setRunId(command.runId());
|
||||
entity.setSourceId(command.sourceId());
|
||||
entity.setFileId(command.fileId());
|
||||
entity.setDetail(command.detail());
|
||||
entity.setDedupKey(dedupKey);
|
||||
@@ -112,18 +113,27 @@ public class FileRunEventStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of incidents, newest first, optionally narrowed to one status and one kind. The kind
|
||||
* filter lives in the query, before the limit: filtering a already-limited page could return
|
||||
* nothing while matching rows exist.
|
||||
* A page of incidents, newest first, optionally narrowed to one status and one kind.
|
||||
*
|
||||
* <p>With no status asked for this is the <em>open</em> queue rather than every row ever
|
||||
* recorded: a dismissed failure has been dealt with, and leaving it in the default view means
|
||||
* the list can never be cleared. Ask for a status to see closed rows.
|
||||
*
|
||||
* <p>Both filters live in the query, before the limit: filtering an already-limited page could
|
||||
* return nothing while matching rows exist.
|
||||
*
|
||||
* <p>{@code actor} narrows to one person's own failures, or reads the whole team when null. Who
|
||||
* gets which is the service's decision, not this method's.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<FileRunEvent> list(
|
||||
Long teamId, FileRunEventStatus status, String kindId, int limit) {
|
||||
Long teamId, FileRunEventStatus status, String kindId, String actor, int limit) {
|
||||
Pageable page = PageRequest.of(0, Math.max(1, limit));
|
||||
List<FileRunEventEntity> rows =
|
||||
status == null
|
||||
? repository.findByTeam(teamId, kindId, page)
|
||||
: repository.findByTeamAndStatus(teamId, status, kindId, page);
|
||||
? repository.findByTeamAndStatusIn(
|
||||
teamId, FileRunEventStatus.open(), kindId, actor, page)
|
||||
: repository.findByTeamAndStatus(teamId, status, kindId, actor, page);
|
||||
return rows.stream().map(FileRunEvent::of).toList();
|
||||
}
|
||||
|
||||
@@ -184,6 +194,22 @@ public class FileRunEventStore {
|
||||
.orElseThrow(() -> refusalFor(id, teamId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Close this owner's open incidents about {@code fileIds}, because the documents are gone. The
|
||||
* rows stay for audit; they just leave the queue. Only open rows move, so a reviewer's dismiss
|
||||
* keeps its meaning and its actor.
|
||||
*
|
||||
* @return how many incidents were closed
|
||||
*/
|
||||
@Transactional
|
||||
public int markFilesRemoved(Long teamId, String actor, Collection<String> fileIds) {
|
||||
if (fileIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
return repository.markFilesRemoved(
|
||||
teamId, actor, fileIds, Instant.now(), FileRunEventStatus.open());
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the guarded UPDATE refused, worked out only once it has. Missing and closed are told
|
||||
* apart after the fact rather than before, so the answer describes the row the UPDATE saw.
|
||||
|
||||
@@ -22,6 +22,7 @@ public record FileRunEventView(
|
||||
String detail,
|
||||
String policyId,
|
||||
String runId,
|
||||
String sourceId,
|
||||
String fileId,
|
||||
String actor,
|
||||
int occurrences,
|
||||
@@ -48,6 +49,7 @@ public record FileRunEventView(
|
||||
event.detail(),
|
||||
event.policyId(),
|
||||
event.runId(),
|
||||
event.sourceId(),
|
||||
event.fileId(),
|
||||
event.actor(),
|
||||
event.occurrences(),
|
||||
|
||||
@@ -30,11 +30,12 @@ public class PolicyFailureRecorder {
|
||||
public void recordRunFailure(
|
||||
String runId,
|
||||
String policyId,
|
||||
String actor,
|
||||
String sourceId,
|
||||
String fileIdentity,
|
||||
String actor,
|
||||
String detail,
|
||||
Throwable cause) {
|
||||
record(classifier.classify(cause), runId, policyId, actor, fileIdentity, detail);
|
||||
record(classifier.classify(cause), runId, policyId, sourceId, fileIdentity, actor, detail);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,21 +45,35 @@ public class PolicyFailureRecorder {
|
||||
* pick the wrong one.
|
||||
*/
|
||||
public void recordRunFailureAs(
|
||||
FailureKind kind, String runId, String policyId, String actor, String detail) {
|
||||
record(kind, runId, policyId, actor, null, detail);
|
||||
FailureKind kind,
|
||||
String runId,
|
||||
String policyId,
|
||||
String sourceId,
|
||||
String actor,
|
||||
String detail) {
|
||||
// No document reference: a run rejected at admission never got as far as one.
|
||||
record(kind, runId, policyId, sourceId, null, actor, detail);
|
||||
}
|
||||
|
||||
private void record(
|
||||
FailureKind kind,
|
||||
String runId,
|
||||
String policyId,
|
||||
String actor,
|
||||
String sourceId,
|
||||
String fileIdentity,
|
||||
String actor,
|
||||
String detail) {
|
||||
try {
|
||||
store.record(
|
||||
RecordFailure.forRun(
|
||||
kind, teamFor(policyId), actor, policyId, runId, fileIdentity, detail));
|
||||
kind,
|
||||
teamFor(policyId),
|
||||
actor,
|
||||
policyId,
|
||||
runId,
|
||||
sourceId,
|
||||
fileIdentity,
|
||||
detail));
|
||||
} catch (RuntimeException e) {
|
||||
// Deliberately swallowed: see the class comment.
|
||||
log.warn("Could not record failure event for run {} (kind {})", runId, kind.getId(), e);
|
||||
|
||||
@@ -38,17 +38,40 @@ public record RecordFailure(
|
||||
detail = truncate(detail);
|
||||
}
|
||||
|
||||
/** A processor-side failure with no file or source context, e.g. a run that failed outright. */
|
||||
/**
|
||||
* A processor-side run failure. {@code sourceId} says which folder, bucket or webhook fed the
|
||||
* run, and is the only attribution an unattended failure has: there is no user to name. {@code
|
||||
* fileId} is the source's opaque reference to the document, already hashed upstream.
|
||||
*/
|
||||
public static RecordFailure forRun(
|
||||
FailureKind kind,
|
||||
Long teamId,
|
||||
String actor,
|
||||
String policyId,
|
||||
String runId,
|
||||
String sourceId,
|
||||
String fileId,
|
||||
String detail) {
|
||||
return new RecordFailure(
|
||||
kind, FailureOrigin.POLICY, teamId, actor, policyId, runId, null, fileId, detail);
|
||||
kind,
|
||||
FailureOrigin.POLICY,
|
||||
teamId,
|
||||
actor,
|
||||
policyId,
|
||||
runId,
|
||||
sourceId,
|
||||
fileId,
|
||||
detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure a user hit in their own editor. There is no policy, run or source: the user is the
|
||||
* attribution, and {@code fileId} may be null when the report named no file.
|
||||
*/
|
||||
public static RecordFailure forEditor(
|
||||
FailureKind kind, Long teamId, String actor, String fileId, String detail) {
|
||||
return new RecordFailure(
|
||||
kind, FailureOrigin.TOOL, teamId, actor, null, null, null, fileId, detail);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,14 +79,21 @@ public record RecordFailure(
|
||||
* reference are the same incident; see {@link #dedupKey()}.
|
||||
*/
|
||||
public String scopeRef() {
|
||||
return switch (kind.getScope()) {
|
||||
case FILE -> nullToEmpty(policyId) + "|" + fileOrRun();
|
||||
case RUN -> nullToEmpty(runId);
|
||||
case POLICY -> nullToEmpty(policyId);
|
||||
case SOURCE -> nullToEmpty(sourceId);
|
||||
// One server-wide condition is one incident regardless of which run tripped over it.
|
||||
case SERVER -> "";
|
||||
};
|
||||
String about =
|
||||
switch (kind.getScope()) {
|
||||
case FILE -> nullToEmpty(policyId) + "|" + fileOrRun();
|
||||
// An editor report has no run, so a RUN-scoped kind would otherwise put every
|
||||
// such failure in a team into one incident: fall back to the document.
|
||||
case RUN -> isBlank(runId) ? fileOrRun() : nullToEmpty(runId);
|
||||
case POLICY -> nullToEmpty(policyId);
|
||||
case SOURCE -> nullToEmpty(sourceId);
|
||||
// One server-wide condition is one incident regardless of which run hit it.
|
||||
case SERVER -> "";
|
||||
};
|
||||
// An editor failure belongs to the person who hit it, so two colleagues hitting the same
|
||||
// thing are two incidents. Folding them would credit one actor for both and offer the
|
||||
// wrong person the row. Unattended runs have no such owner and are unaffected.
|
||||
return origin == FailureOrigin.TOOL ? nullToEmpty(actor) + "|" + about : about;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||