Compare commits

..
Author SHA1 Message Date
Anthony Stirling bbb6058339 Add cleanup for auto deployed PRs 2025-07-20 11:47:47 +01:00
106 changed files with 2215 additions and 3475 deletions
+14 -7
View File
@@ -1,22 +1,29 @@
build: &build
- build.gradle
- app/(common|core|proprietary)/build.gradle
- app/core/build.gradle
- app/common/build.gradle
- app/proprietary/build.gradle
app: &app
- app/(common|core|proprietary)/src/main/java/**
- app/core/src/main/java/**
- app/common/src/main/java/**
- app/proprietary/src/main/java/**
openapi: &openapi
- build.gradle
- app/(common|core|proprietary)/build.gradle
- app/(common|core|proprietary)/src/main/java/**
- app/core/build.gradle
- app/core/src/main/java/**
- app/common/build.gradle
- app/common/src/main/java/**
- app/proprietary/build.gradle
- app/proprietary/src/main/java/**
project: &project
- app/(common|core|proprietary)/src/(main|test)/java/**
- app/(common|core|proprietary)/build.gradle
- 'app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*'
- app/**
- exampleYmlFiles/**
- gradle/**
- libs/**
- scripts/**
- testing/**
- build.gradle
- Dockerfile
+4 -5
View File
@@ -76,9 +76,8 @@ labels:
- 'app/core/src/main/resources/settings.yml.template'
- 'app/core/src/main/resources/application.properties'
- 'app/core/src/main/resources/banner.txt'
- 'app/core/src/main/resources/static/python/png_to_webp.py'
- 'app/core/src/main/resources/static/python/split_photos.py'
- 'app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/**'
- 'scripts/png_to_webp.py'
- 'split_photos.py'
- 'application.properties'
- label: 'Security'
@@ -96,8 +95,8 @@ labels:
- 'app/core/src/main/java/stirling/software/SPDF/model/api/.*'
- 'app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java'
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/.*'
- 'app/core/src/main/resources/static/python/png_to_webp.py'
- 'app/core/src/main/resources/static/python/split_photos.py'
- 'scripts/png_to_webp.py'
- 'split_photos.py'
- '.github/workflows/swagger.yml'
- label: 'Documentation'
@@ -41,7 +41,7 @@ jobs:
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -152,7 +152,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -180,6 +180,7 @@ jobs:
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- name: Run Gradle Command
run: |
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+374
View File
@@ -0,0 +1,374 @@
name: PR Auto Deploy
on:
pull_request_target:
branches: ["main"]
types: [opened, synchronize, reopened, closed]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
check-pr:
runs-on: ubuntu-latest
if: |
github.event.action != 'closed' &&
(
github.event.pull_request.user.login == 'frooodle' ||
github.event.pull_request.user.login == 'sf298' ||
github.event.pull_request.user.login == 'Ludy87' ||
github.event.pull_request.user.login == 'LaserKaspar' ||
github.event.pull_request.user.login == 'sbplat' ||
github.event.pull_request.user.login == 'reecebrowne' ||
github.event.pull_request.user.login == 'DarioGii' ||
github.event.pull_request.user.login == 'EthanHealy01' ||
github.event.pull_request.user.login == 'ConnorYoh'
)
outputs:
pr_number: ${{ steps.get-pr.outputs.pr_number }}
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
disable_security: 'true'
enable_pro: 'false'
enable_enterprise: 'false'
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get PR data
id: get-pr
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const prNumber = context.payload.pull_request.number;
console.log(`PR Number: ${prNumber}`);
core.setOutput('pr_number', prNumber);
- name: Get PR repository and ref
id: get-pr-info
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const repository = pr.head.repo.fork ? pr.head.repo.full_name : `${owner}/${repo}`;
console.log(`PR Repository: ${repository}`);
console.log(`PR Branch: ${pr.head.ref}`);
core.setOutput('repository', repository);
core.setOutput('ref', pr.head.ref);
deploy-pr:
if: github.event.action != 'closed'
needs: check-pr
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: ${{ needs.check-pr.outputs.pr_repository }}
ref: ${{ needs.check-pr.outputs.pr_ref }}
token: ${{ steps.setup-bot.outputs.token }}
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- name: Run Gradle Command
run: |
if [ "${{ needs.check-pr.outputs.disable_security }}" == "true" ]; then
export DISABLE_ADDITIONAL_FEATURES=true
else
export DISABLE_ADDITIONAL_FEATURES=false
fi
./gradlew clean build
env:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push PR-specific image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-pr.outputs.pr_number }}
build-args: VERSION_TAG=alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Deploy to VPS
id: deploy
run: |
# Set security settings based on flags
if [ "${{ needs.check-pr.outputs.disable_security }}" == "false" ]; then
DISABLE_ADDITIONAL_FEATURES="false"
LOGIN_SECURITY="true"
SECURITY_STATUS="🔒 Security Enabled"
else
DISABLE_ADDITIONAL_FEATURES="true"
LOGIN_SECURITY="false"
SECURITY_STATUS="Security Disabled"
fi
# Set pro/enterprise settings (enterprise implies pro)
if [ "${{ needs.check-pr.outputs.enable_enterprise }}" == "true" ]; then
PREMIUM_ENABLED="true"
PREMIUM_KEY="${{ secrets.ENTERPRISE_KEY }}"
PREMIUM_PROFEATURES_AUDIT_ENABLED="true"
elif [ "${{ needs.check-pr.outputs.enable_pro }}" == "true" ]; then
PREMIUM_ENABLED="true"
PREMIUM_KEY="${{ secrets.PREMIUM_KEY }}"
PREMIUM_PROFEATURES_AUDIT_ENABLED="true"
else
PREMIUM_ENABLED="false"
PREMIUM_KEY=""
PREMIUM_PROFEATURES_AUDIT_ENABLED="false"
fi
# First create the docker-compose content locally
cat > docker-compose.yml << EOF
version: '3.3'
services:
stirling-pdf:
container_name: stirling-pdf-pr-${{ needs.check-pr.outputs.pr_number }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-pr.outputs.pr_number }}
ports:
- "${{ needs.check-pr.outputs.pr_number }}:8080"
volumes:
- /stirling/PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw
- /stirling/PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw
- /stirling/PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "${DISABLE_ADDITIONAL_FEATURES}"
SECURITY_ENABLELOGIN: "${LOGIN_SECURITY}"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF PR#${{ needs.check-pr.outputs.pr_number }}"
UI_HOMEDESCRIPTION: "PR#${{ needs.check-pr.outputs.pr_number }} for Stirling-PDF Latest"
UI_APPNAMENAVBAR: "PR#${{ needs.check-pr.outputs.pr_number }}"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
PREMIUM_KEY: "${PREMIUM_KEY}"
PREMIUM_ENABLED: "${PREMIUM_ENABLED}"
PREMIUM_PROFEATURES_AUDIT_ENABLED: "${PREMIUM_PROFEATURES_AUDIT_ENABLED}"
restart: on-failure:5
EOF
# Then copy the file and execute commands
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
# Create PR-specific directories
mkdir -p /stirling/PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
# Move docker-compose file to correct location
mv /tmp/docker-compose.yml /stirling/PR-${{ needs.check-pr.outputs.pr_number }}/docker-compose.yml
# Start or restart the container
cd /stirling/PR-${{ needs.check-pr.outputs.pr_number }}
docker-compose pull
docker-compose up -d
ENDSSH
# Set output for use in PR comment
echo "security_status=${SECURITY_STATUS}" >> $GITHUB_ENV
- name: Add 'pr-deployed' label
if: success()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['pr-deployed']
});
- name: Post deployment URL to PR
if: success()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${prNumber}`;
const securityStatus = process.env.security_status || "Security Disabled";
const body = `## 🚀 PR Test Deployment\n\n` +
`Your PR has been deployed for testing!\n\n` +
`🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` +
`${securityStatus}\n\n` +
`This deployment will be automatically cleaned up when the PR is closed.\n\n`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body
});
- name: Cleanup temporary files
if: always()
run: |
echo "Cleaning up temporary files..."
rm -f ../private.key docker-compose.yml
echo "Cleanup complete."
continue-on-error: true
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.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@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const prNumber = ${{ github.event.pull_request.number }};
const owner = context.repo.owner;
const repo = context.repo.repo;
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: prNumber
});
const hasLabel = labels.some(label => label.name === 'pr-deployed');
if (hasLabel) {
console.log("Label 'pr-deployed' found. Removing...");
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: 'pr-deployed'
});
} else {
console.log("Label 'pr-deployed' not found. Nothing to do.");
}
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber
});
const deploymentComments = comments.filter(c =>
c.body?.includes("## 🚀 PR Test Deployment") &&
c.user?.type === "Bot"
);
if (deploymentComments.length > 0) {
for (const comment of deploymentComments) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
console.log(`Deleted deployment comment (ID: ${comment.id})`);
}
} else {
console.log("No matching deployment comments found.");
}
const hasDeploymentComment = deploymentComments.length > 0;
core.setOutput('present', (hasLabel || hasDeploymentComment) ? 'true' : 'false');
- name: Set up SSH
if: steps.remove-label-comment.outputs.present == 'true'
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Cleanup PR deployment
if: steps.remove-label-comment.outputs.present == 'true'
id: cleanup
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then
echo "Found PR directory, proceeding with cleanup..."
cd /stirling/PR-${{ github.event.pull_request.number }}
docker-compose down || true
cd /
rm -rf /stirling/PR-${{ github.event.pull_request.number }}
docker rmi --no-prune ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ github.event.pull_request.number }} || true
echo "PERFORMED_CLEANUP"
else
echo "PR directory not found, nothing to clean up"
echo "NO_CLEANUP_NEEDED"
fi
ENDSSH
- name: Cleanup temporary files
if: always()
run: |
echo "Cleaning up temporary files..."
rm -f ../private.key
echo "Cleanup complete."
continue-on-error: true
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -87,7 +87,7 @@ jobs:
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@9693b137b6566bb66055a713613bf4f0493701eb # v1.2.3
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+57 -44
View File
@@ -7,40 +7,10 @@ on:
pull_request:
branches: ["main"]
# 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.event.pull_request.number || github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
files-changed:
name: detect what files changed
runs-on: ubuntu-latest
timeout-minutes: 3
# Map a step output to a job output
outputs:
build: ${{ steps.changes.outputs.build }}
app: ${{ steps.changes.outputs.app }}
project: ${{ steps.changes.outputs.project }}
openapi: ${{ steps.changes.outputs.openapi }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
filters: ".github/config/.files.yaml"
build:
runs-on: ubuntu-latest
@@ -56,7 +26,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -68,6 +38,7 @@ jobs:
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
cache: gradle
- name: Setup Gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
@@ -124,56 +95,74 @@ jobs:
if-no-files-found: warn
check-generateOpenApiDocs:
if: needs.files-changed.outputs.openapi == 'true'
needs: [files-changed, build]
needs: build
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Filter for integration changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: files
with:
filters: ".github/config/.files.yaml"
- name: Set up JDK 17
if: ${{ steps.files.outputs.openapi == 'true' }}
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- name: Setup Gradle
if: ${{ steps.files.outputs.openapi == 'true' }}
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Generate OpenAPI documentation
if: ${{ steps.files.outputs.openapi == 'true' }}
run: ./gradlew :stirling-pdf:generateOpenApiDocs
- name: Upload OpenAPI Documentation
if: ${{ steps.files.outputs.openapi == 'true' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: openapi-docs
path: ./SwaggerDoc.json
check-licence:
if: needs.files-changed.outputs.build == 'true'
needs: [files-changed, build]
needs: build
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Filter for integration changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: files
with:
filters: ".github/config/.files.yaml"
- name: Set up JDK 17
if: ${{ steps.files.outputs.build == 'true' }}
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- name: check the licenses for compatibility
if: ${{ steps.files.outputs.build == 'true' }}
run: ./gradlew clean checkLicense
- name: FAILED - check the licenses for compatibility
@@ -186,8 +175,6 @@ jobs:
retention-days: 3
docker-compose-tests:
if: needs.files-changed.outputs.project == 'true'
needs: files-changed
# if: github.event_name == 'push' && github.ref == 'refs/heads/main' ||
# (github.event_name == 'pull_request' &&
# contains(github.event.pull_request.labels.*.name, 'licenses') == false &&
@@ -206,28 +193,39 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Filter for integration changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: files
with:
filters: ".github/config/.files.yaml"
- name: Set up Java 17
if: ${{ steps.files.outputs.project == 'true' }}
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- name: Set up Docker Buildx
if: ${{ steps.files.outputs.project == 'true' }}
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Install Docker Compose
if: ${{ steps.files.outputs.project == 'true' }}
run: |
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.37.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
if: ${{ steps.files.outputs.project == 'true' }}
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
@@ -235,10 +233,12 @@ jobs:
cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Pip requirements
if: ${{ steps.files.outputs.project == 'true' }}
run: |
pip install --require-hashes -r ./testing/cucumber/requirements.txt
- name: Run Docker Compose Tests
if: ${{ steps.files.outputs.project == 'true' }}
run: |
chmod +x ./testing/test_webpages.sh
chmod +x ./testing/test.sh
@@ -246,8 +246,8 @@ jobs:
./testing/test.sh
test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
needs: [files-changed, build, check-generateOpenApiDocs, check-licence]
if: github.event_name == 'pull_request'
needs: [build, check-generateOpenApiDocs, check-licence]
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -255,38 +255,51 @@ jobs:
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Filter for integration changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: files
with:
filters: ".github/config/.files.yaml"
- name: Set up JDK 17
if: ${{ steps.files.outputs.project == 'true' }}
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- name: Set up Gradle
if: ${{ steps.files.outputs.project == 'true' }}
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
- name: Build application
if: ${{ steps.files.outputs.project == 'true' }}
run: ./gradlew clean build
env:
DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: false
- name: Set up QEMU
if: ${{ steps.files.outputs.project == 'true' }}
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
if: ${{ steps.files.outputs.project == 'true' }}
id: buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build ${{ matrix.docker-rev }}
if: ${{ steps.files.outputs.project == 'true' }}
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
+3 -15
View File
@@ -6,18 +6,6 @@ on:
paths:
- "app/core/src/main/resources/messages_*.properties"
# 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.event.pull_request.number || github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read # Allow read access to repository content
@@ -30,7 +18,7 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -127,7 +115,7 @@ jobs:
// Filter for relevant files based on the PR changes
const changedFiles = files
.filter(file =>
.filter(file =>
file.status !== "removed" &&
/^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
)
@@ -289,4 +277,4 @@ jobs:
rm -rf pr-branch
rm -f pr-branch-messages_en_GB.properties main-branch-messages_en_GB.properties changed_files.txt result.txt
echo "Cleanup complete."
continue-on-error: true # Ensure cleanup runs even if previous steps fail
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+2 -13
View File
@@ -7,18 +7,6 @@ on:
paths:
- "build.gradle"
# 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
@@ -31,7 +19,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -52,6 +40,7 @@ jobs:
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- name: Setup Gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+10 -7
View File
@@ -21,7 +21,7 @@ jobs:
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -32,6 +32,7 @@ jobs:
with:
distribution: 'temurin'
java-version: '21'
cache: gradle
# ✅ Get version from Gradle
- name: Get version number
@@ -60,7 +61,7 @@ jobs:
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -71,6 +72,7 @@ jobs:
with:
java-version: "21"
distribution: "temurin"
cache: gradle
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
@@ -110,7 +112,7 @@ jobs:
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -148,7 +150,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -159,6 +161,7 @@ jobs:
with:
java-version: "21"
distribution: "temurin"
cache: gradle
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
@@ -238,7 +241,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -252,7 +255,7 @@ jobs:
- name: Install Cosign
if: matrix.os == 'windows-latest'
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
- name: Generate key pair
if: matrix.os == 'windows-latest'
@@ -301,7 +304,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+3 -13
View File
@@ -2,9 +2,8 @@ name: Pre-commit
on:
workflow_dispatch:
push:
branches:
- main
schedule:
- cron: "0 0 * * 1"
permissions:
contents: read
@@ -17,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -47,15 +46,6 @@ jobs:
- run: pre-commit run --all-files -c .pre-commit-config.yaml
continue-on-error: true
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: 17
distribution: "temurin"
- name: Build with Gradle
run: ./gradlew clean build
- name: git add
run: |
git add .
+3 -14
View File
@@ -7,18 +7,6 @@ on:
- master
- main
# 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
@@ -30,7 +18,7 @@ jobs:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -41,6 +29,7 @@ jobs:
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
@@ -54,7 +43,7 @@ jobs:
- name: Install cosign
if: github.ref == 'refs/heads/master'
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
with:
cosign-release: "v2.4.1"
+5 -4
View File
@@ -23,7 +23,7 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -34,6 +34,7 @@ jobs:
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
@@ -83,7 +84,7 @@ jobs:
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -95,7 +96,7 @@ jobs:
run: ls -R
- name: Install Cosign
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
- name: Generate key pair
run: cosign generate-key-pair
@@ -161,7 +162,7 @@ jobs:
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -74,6 +74,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
with:
sarif_file: results.sarif
+1 -13
View File
@@ -9,18 +9,6 @@ on:
- main
workflow_dispatch:
# 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.event.pull_request.number || github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
pull-requests: read
actions: read
@@ -30,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+2 -13
View File
@@ -6,18 +6,6 @@ on:
branches:
- master
# 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
@@ -26,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -37,6 +25,7 @@ jobs:
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
+1 -13
View File
@@ -12,18 +12,6 @@ on:
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
# 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
@@ -32,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+4 -15
View File
@@ -4,18 +4,6 @@ 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
@@ -24,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -36,6 +24,7 @@ jobs:
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Setup Gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
@@ -122,7 +111,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -156,7 +145,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+4 -4
View File
@@ -124,10 +124,10 @@ SwaggerDoc.json
*.tar.gz
*.rar
*.db
build
app/core/build
app/common/build
app/proprietary/build
/build
/app/core/build
/app/common/build
/app/proprietary/build
common/build
proprietary/build
stirling-pdf/build
+5 -5
View File
@@ -1,15 +1,15 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.7
rev: v0.12.0
hooks:
- id: ruff
args:
- --fix
- --line-length=127
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
files: ^((\.github/scripts|scripts)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- id: ruff-format
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
files: ^((\.github/scripts|scripts)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
@@ -22,7 +22,7 @@ repos:
files: \.(html|css|js|py|md)$
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.28.0
rev: v8.27.2
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -43,4 +43,4 @@ repos:
# - stylelint-config-standard@38.0.0
# - "@stylistic/stylelint-plugin@3.1.3"
# files: \.(css)$
# args: [--fix]
# args: [--fix]
+6 -11
View File
@@ -1,8 +1,9 @@
# Main stage
FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
# Copy necessary files
COPY scripts /scripts
COPY pipeline /pipeline
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
COPY app/core/build/libs/*.jar app.jar
@@ -43,7 +44,6 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk update && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
@@ -56,11 +56,6 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
openssl \
openssl-dev \
openjdk21-jre \
# Security updates - remove when Alpine base image updates these
# libjxl fixes CVE-2024-11403, CVE-2024-11498
# rav1e fixes CVE-2025-4574, GHSA-2rxc-gjrp-vjhx, RUSTSEC-2024-0404, GHSA-pg9f-39pc-qf8g
libjxl \
rav1e \
# Doc conversion
gcompat \
libc6-compat \
@@ -79,13 +74,12 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
python3 \
ocrmypdf \
py3-pip \
# py3-pillow fixes CVE-2025-48379 - ensure Pillow 11.3.0+ instead of 11.2.1
py3-pillow \
py3-pdf2image \
py3-pillow@testing \
py3-pdf2image@testing \
# URW Base 35 fonts for better PDF rendering
font-urw-base35 && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --no-cache-dir --upgrade pip setuptools && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
@@ -96,6 +90,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
ln -s /usr/share/fontconfig/conf.avail/69-urw-*.conf /etc/fonts/conf.d/ && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
+3 -2
View File
@@ -36,7 +36,7 @@ ENV SETUPTOOLS_USE_DISTUTILS=local \
# Installation der benötigten Python-Pakete
RUN python3 -m venv --system-site-packages /opt/venv \
&& . /opt/venv/bin/activate \
&& pip install --no-cache-dir --upgrade pip setuptools \
&& pip install --upgrade pip setuptools \
&& pip install --no-cache-dir WeasyPrint pdf2image pillow unoserver opencv-python-headless pre-commit
# Füge den venv-Pfad zur globalen PATH-Variable hinzu, damit die Tools verfügbar sind
@@ -54,7 +54,8 @@ RUN echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser \
# Setze das Arbeitsverzeichnis (wird später per Bind-Mount überschrieben)
WORKDIR /workspace
RUN chmod +x /workspace/.devcontainer/git-init.sh /workspace/.devcontainer/init-setup.sh
RUN chmod +x /workspace/.devcontainer/git-init.sh
RUN sudo chmod +x /workspace/.devcontainer/init-setup.sh
# Wechsel zum NichtRoot Benutzer
USER devuser
+6 -11
View File
@@ -22,10 +22,11 @@ RUN DISABLE_ADDITIONAL_FEATURES=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Main stage
FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
# Copy necessary files
COPY scripts /scripts
COPY pipeline /pipeline
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# first /app directory is for the build stage, second is for the final image
COPY --from=build /app/app/core/build/libs/*.jar app.jar
@@ -57,7 +58,6 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk update && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
@@ -70,11 +70,6 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
openssl \
openssl-dev \
openjdk21-jre \
# Security updates - remove when Alpine base image updates these
# libjxl fixes CVE-2024-11403, CVE-2024-11498
# rav1e fixes CVE-2025-4574, GHSA-2rxc-gjrp-vjhx, RUSTSEC-2024-0404, GHSA-pg9f-39pc-qf8g
libjxl \
rav1e \
# Doc conversion
gcompat \
libc6-compat \
@@ -94,11 +89,10 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
python3 \
ocrmypdf \
py3-pip \
# py3-pillow fixes CVE-2025-48379 - ensure Pillow 11.3.0+ instead of 11.2.1
py3-pillow \
py3-pdf2image && \
py3-pillow@testing \
py3-pdf2image@testing && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --no-cache-dir --upgrade pip setuptools && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
@@ -109,6 +103,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
ln -s /usr/share/fontconfig/conf.avail/69-urw-*.conf /etc/fonts/conf.d/ && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
+7 -12
View File
@@ -1,5 +1,5 @@
# use alpine
FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
ARG VERSION_TAG
@@ -21,13 +21,13 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh
COPY pipeline /pipeline
COPY app/core/build/libs/*.jar app.jar
# Set up necessary directories and permissions
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk update && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
@@ -37,17 +37,12 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
curl \
shadow \
su-exec \
openjdk21-jre \
# Security updates - remove when Alpine base image updates these
# libjxl fixes CVE-2024-11403, CVE-2024-11498
# rav1e fixes CVE-2025-4574, GHSA-2rxc-gjrp-vjhx, RUSTSEC-2024-0404, GHSA-pg9f-39pc-qf8g
libjxl \
rav1e && \
openjdk21-jre && \
# User permissions
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf && \
chmod +x /scripts/*.sh && \
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /tmp/stirling-pdf && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
# Set environment variables
+2 -2
View File
@@ -143,7 +143,7 @@ Stirling-PDF currently supports 40 languages!
| Portuguese (Português) (pt_PT) | ![70%](https://geps.dev/progress/70) |
| Portuguese Brazilian (Português) (pt_BR) | ![77%](https://geps.dev/progress/77) |
| Romanian (Română) (ro_RO) | ![59%](https://geps.dev/progress/59) |
| Russian (Русский) (ru_RU) | ![90%](https://geps.dev/progress/90) |
| Russian (Русский) (ru_RU) | ![81%](https://geps.dev/progress/81) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![97%](https://geps.dev/progress/97) |
| Simplified Chinese (简体中文) (zh_CN) | ![95%](https://geps.dev/progress/95) |
| Slovakian (Slovensky) (sk_SK) | ![53%](https://geps.dev/progress/53) |
@@ -152,7 +152,7 @@ Stirling-PDF currently supports 40 languages!
| Swedish (Svenska) (sv_SE) | ![67%](https://geps.dev/progress/67) |
| Thai (ไทย) (th_TH) | ![60%](https://geps.dev/progress/60) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![66%](https://geps.dev/progress/66) |
| Traditional Chinese (繁體中文) (zh_TW) | ![99%](https://geps.dev/progress/99) |
| Traditional Chinese (繁體中文) (zh_TW) | ![77%](https://geps.dev/progress/77) |
| Turkish (Türkçe) (tr_TR) | ![82%](https://geps.dev/progress/82) |
| Ukrainian (Українська) (uk_UA) | ![72%](https://geps.dev/progress/72) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![58%](https://geps.dev/progress/58) |
+1 -13
View File
@@ -4,7 +4,7 @@ bootRun {
}
spotless {
java {
target 'src/**/java/**/*.java'
target sourceSets.main.allJava
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
@@ -13,18 +13,6 @@ spotless {
leadingTabsToSpaces()
endWithNewline()
}
yaml {
target '**/*.yml', '**/*.yaml'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
format 'gradle', {
target '**/gradle/*.gradle', '**/*.gradle'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
}
dependencies {
api 'org.springframework.boot:spring-boot-starter-web'
@@ -14,8 +14,6 @@ public class InstallationPathConfig {
private static final String CONFIG_PATH;
private static final String CUSTOM_FILES_PATH;
private static final String CLIENT_WEBUI_PATH;
private static final String SCRIPTS_PATH;
private static final String PIPELINE_PATH;
// Config paths
private static final String SETTINGS_PATH;
@@ -34,12 +32,10 @@ public class InstallationPathConfig {
CONFIG_PATH = BASE_PATH + "configs" + File.separator;
CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator;
CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator;
PIPELINE_PATH = BASE_PATH + "pipeline" + File.separator;
// Initialize config paths
SETTINGS_PATH = CONFIG_PATH + "settings.yml";
CUSTOM_SETTINGS_PATH = CONFIG_PATH + "custom_settings.yml";
SCRIPTS_PATH = CONFIG_PATH + "scripts" + File.separator;
// Initialize custom file paths
STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator;
@@ -93,14 +89,6 @@ public class InstallationPathConfig {
return CLIENT_WEBUI_PATH;
}
public static String getScriptsPath() {
return SCRIPTS_PATH;
}
public static String getPipelinePath() {
return PIPELINE_PATH;
}
public static String getSettingsPath() {
return SETTINGS_PATH;
}
@@ -25,9 +25,6 @@ import org.springframework.core.io.Resource;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
@@ -61,10 +58,7 @@ public class ApplicationProperties {
private Mail mail = new Mail();
private Premium premium = new Premium();
@JsonIgnore // Deprecated - completely hidden from JSON serialization
private EnterpriseEdition enterpriseEdition = new EnterpriseEdition();
private AutoPipeline autoPipeline = new AutoPipeline();
private ProcessExecutor processExecutor = new ProcessExecutor();
@@ -174,27 +168,14 @@ public class ApplicationProperties {
private Boolean autoCreateUser = false;
private Boolean blockRegistration = false;
private String registrationId = "stirling";
@ToString.Exclude
@JsonProperty("idpMetadataUri")
private String idpMetadataUri;
@ToString.Exclude private String idpMetadataUri;
private String idpSingleLogoutUrl;
private String idpSingleLoginUrl;
private String idpIssuer;
@JsonProperty("idpCert")
private String idpCert;
@ToString.Exclude private String privateKey;
@ToString.Exclude private String spCert;
@ToString.Exclude
@JsonProperty("privateKey")
private String privateKey;
@ToString.Exclude
@JsonProperty("spCert")
private String spCert;
@JsonIgnore
public InputStream getIdpMetadataUri() throws IOException {
if (idpMetadataUri.startsWith("classpath:")) {
return new ClassPathResource(idpMetadataUri.substring("classpath".length()))
@@ -211,7 +192,6 @@ public class ApplicationProperties {
}
}
@JsonIgnore
public Resource getSpCert() {
if (spCert == null) return null;
if (spCert.startsWith("classpath:")) {
@@ -221,7 +201,6 @@ public class ApplicationProperties {
}
}
@JsonIgnore
public Resource getIdpCert() {
if (idpCert == null) return null;
if (idpCert.startsWith("classpath:")) {
@@ -231,7 +210,6 @@ public class ApplicationProperties {
}
}
@JsonIgnore
public Resource getPrivateKey() {
if (privateKey.startsWith("classpath:")) {
return new ClassPathResource(privateKey.substring("classpath:".length()));
@@ -312,7 +290,6 @@ public class ApplicationProperties {
private Datasource datasource;
private Boolean disableSanitize;
private Boolean enableUrlToPDF;
private Html html = new Html();
private CustomPaths customPaths = new CustomPaths();
private String fileUploadLimit;
private TempFileManagement tempFileManagement = new TempFileManagement();
@@ -343,12 +320,8 @@ public class ApplicationProperties {
@Data
public static class TempFileManagement {
@JsonProperty("baseTmpDir")
private String baseTmpDir = "";
@JsonProperty("libreofficeDir")
private String libreofficeDir = "";
private String systemTempDir = "";
private String prefix = "stirling-pdf-";
private long maxAgeHours = 24;
@@ -356,14 +329,12 @@ public class ApplicationProperties {
private boolean startupCleanup = true;
private boolean cleanupSystemTemp = false;
@JsonIgnore
public String getBaseTmpDir() {
return baseTmpDir != null && !baseTmpDir.isEmpty()
? baseTmpDir
: java.lang.System.getProperty("java.io.tmpdir") + "/stirling-pdf";
}
@JsonIgnore
public String getLibreofficeDir() {
return libreofficeDir != null && !libreofficeDir.isEmpty()
? libreofficeDir
@@ -371,25 +342,6 @@ public class ApplicationProperties {
}
}
@Data
public static class Html {
private UrlSecurity urlSecurity = new UrlSecurity();
@Data
public static class UrlSecurity {
private boolean enabled = true;
private String level = "MEDIUM"; // MAX, MEDIUM, OFF
private List<String> allowedDomains = new ArrayList<>();
private List<String> blockedDomains = new ArrayList<>();
private List<String> internalTlds =
Arrays.asList(".local", ".internal", ".corp", ".home");
private boolean blockPrivateNetworks = true;
private boolean blockLocalhost = true;
private boolean blockLinkLocal = true;
private boolean blockCloudMetadata = true;
}
}
@Data
public static class Datasource {
private boolean enableCustomDatabase;
@@ -639,24 +591,12 @@ public class ApplicationProperties {
@Data
public static class TimeoutMinutes {
@JsonProperty("libreOfficetimeoutMinutes")
private long libreOfficeTimeoutMinutes;
@JsonProperty("pdfToHtmltimeoutMinutes")
private long pdfToHtmlTimeoutMinutes;
@JsonProperty("pythonOpenCvtimeoutMinutes")
private long pythonOpenCvTimeoutMinutes;
@JsonProperty("weasyPrinttimeoutMinutes")
private long weasyPrintTimeoutMinutes;
@JsonProperty("installApptimeoutMinutes")
private long installAppTimeoutMinutes;
@JsonProperty("calibretimeoutMinutes")
private long calibreTimeoutMinutes;
private long tesseractTimeoutMinutes;
private long qpdfTimeoutMinutes;
private long ghostscriptTimeoutMinutes;
@@ -1,208 +0,0 @@
package stirling.software.common.service;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@Service
@RequiredArgsConstructor
@Slf4j
public class SsrfProtectionService {
private final ApplicationProperties applicationProperties;
private static final Pattern DATA_URL_PATTERN =
Pattern.compile("^data:.*", Pattern.CASE_INSENSITIVE);
private static final Pattern FRAGMENT_PATTERN = Pattern.compile("^#.*");
public enum SsrfProtectionLevel {
OFF, // No SSRF protection - allows all URLs
MEDIUM, // Block internal networks but allow external URLs
MAX // Block all external URLs - only data: and fragments
}
public boolean isUrlAllowed(String url) {
ApplicationProperties.Html.UrlSecurity config =
applicationProperties.getSystem().getHtml().getUrlSecurity();
if (!config.isEnabled()) {
return true;
}
if (url == null || url.trim().isEmpty()) {
return false;
}
String trimmedUrl = url.trim();
// Always allow data URLs and fragments
if (DATA_URL_PATTERN.matcher(trimmedUrl).matches()
|| FRAGMENT_PATTERN.matcher(trimmedUrl).matches()) {
return true;
}
SsrfProtectionLevel level = parseProtectionLevel(config.getLevel());
switch (level) {
case OFF:
return true;
case MAX:
return isMaxSecurityAllowed(trimmedUrl, config);
case MEDIUM:
return isMediumSecurityAllowed(trimmedUrl, config);
default:
return false;
}
}
private SsrfProtectionLevel parseProtectionLevel(String level) {
try {
return SsrfProtectionLevel.valueOf(level.toUpperCase());
} catch (IllegalArgumentException e) {
log.warn("Invalid SSRF protection level '{}', defaulting to MEDIUM", level);
return SsrfProtectionLevel.MEDIUM;
}
}
private boolean isMaxSecurityAllowed(
String url, ApplicationProperties.Html.UrlSecurity config) {
// MAX security: only allow explicitly whitelisted domains
try {
URI uri = new URI(url);
String host = uri.getHost();
if (host == null) {
return false;
}
return config.getAllowedDomains().contains(host.toLowerCase());
} catch (Exception e) {
log.debug("Failed to parse URL for MAX security check: {}", url, e);
return false;
}
}
private boolean isMediumSecurityAllowed(
String url, ApplicationProperties.Html.UrlSecurity config) {
try {
URI uri = new URI(url);
String host = uri.getHost();
if (host == null) {
return false;
}
String hostLower = host.toLowerCase();
// Check explicit blocked domains
if (config.getBlockedDomains().contains(hostLower)) {
log.debug("URL blocked by explicit domain blocklist: {}", url);
return false;
}
// Check internal TLD patterns
for (String tld : config.getInternalTlds()) {
if (hostLower.endsWith(tld.toLowerCase())) {
log.debug("URL blocked by internal TLD pattern '{}': {}", tld, url);
return false;
}
}
// If allowedDomains is specified, only allow those
if (!config.getAllowedDomains().isEmpty()) {
boolean isAllowed =
config.getAllowedDomains().stream()
.anyMatch(
domain ->
hostLower.equals(domain.toLowerCase())
|| hostLower.endsWith(
"." + domain.toLowerCase()));
if (!isAllowed) {
log.debug("URL not in allowed domains list: {}", url);
return false;
}
}
// Resolve hostname to IP address for network-based checks
try {
InetAddress address = InetAddress.getByName(host);
if (config.isBlockPrivateNetworks() && isPrivateAddress(address)) {
log.debug("URL blocked - private network address: {}", url);
return false;
}
if (config.isBlockLocalhost() && address.isLoopbackAddress()) {
log.debug("URL blocked - localhost address: {}", url);
return false;
}
if (config.isBlockLinkLocal() && address.isLinkLocalAddress()) {
log.debug("URL blocked - link-local address: {}", url);
return false;
}
if (config.isBlockCloudMetadata()
&& isCloudMetadataAddress(address.getHostAddress())) {
log.debug("URL blocked - cloud metadata endpoint: {}", url);
return false;
}
} catch (UnknownHostException e) {
log.debug("Failed to resolve hostname for SSRF check: {}", host, e);
return false;
}
return true;
} catch (Exception e) {
log.debug("Failed to parse URL for MEDIUM security check: {}", url, e);
return false;
}
}
private boolean isPrivateAddress(InetAddress address) {
return address.isSiteLocalAddress()
|| address.isAnyLocalAddress()
|| isPrivateIPv4Range(address.getHostAddress());
}
private boolean isPrivateIPv4Range(String ip) {
return ip.startsWith("10.")
|| ip.startsWith("192.168.")
|| (ip.startsWith("172.") && isInRange172(ip))
|| ip.startsWith("127.")
|| "0.0.0.0".equals(ip);
}
private boolean isInRange172(String ip) {
String[] parts = ip.split("\\.");
if (parts.length >= 2) {
try {
int secondOctet = Integer.parseInt(parts[1]);
return secondOctet >= 16 && secondOctet <= 31;
} catch (NumberFormatException e) {
return false;
}
}
return false;
}
private boolean isCloudMetadataAddress(String ip) {
// Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud
return ip.startsWith("169.254.169.254") // AWS/GCP/Azure
|| ip.startsWith("fd00:ec2::254") // AWS IPv6
|| ip.startsWith("169.254.169.253") // Oracle Cloud
|| ip.startsWith("169.254.169.250"); // IBM Cloud
}
}
@@ -1,71 +1,21 @@
package stirling.software.common.util;
import org.owasp.html.AttributePolicy;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.owasp.html.Sanitizers;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.SsrfProtectionService;
@Component
public class CustomHtmlSanitizer {
private final SsrfProtectionService ssrfProtectionService;
private final ApplicationProperties applicationProperties;
@Autowired
public CustomHtmlSanitizer(
SsrfProtectionService ssrfProtectionService,
ApplicationProperties applicationProperties) {
this.ssrfProtectionService = ssrfProtectionService;
this.applicationProperties = applicationProperties;
}
private final AttributePolicy SSRF_SAFE_URL_POLICY =
new AttributePolicy() {
@Override
public String apply(String elementName, String attributeName, String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
String trimmedValue = value.trim();
// Use the SSRF protection service to validate the URL
if (ssrfProtectionService != null
&& !ssrfProtectionService.isUrlAllowed(trimmedValue)) {
return null;
}
return trimmedValue;
}
};
private final PolicyFactory SSRF_SAFE_IMAGES_POLICY =
new HtmlPolicyBuilder()
.allowElements("img")
.allowAttributes("alt", "width", "height", "title")
.onElements("img")
.allowAttributes("src")
.matching(SSRF_SAFE_URL_POLICY)
.onElements("img")
.toFactory();
private final PolicyFactory POLICY =
private static final PolicyFactory POLICY =
Sanitizers.FORMATTING
.and(Sanitizers.BLOCKS)
.and(Sanitizers.STYLES)
.and(Sanitizers.LINKS)
.and(Sanitizers.TABLES)
.and(SSRF_SAFE_IMAGES_POLICY)
.and(Sanitizers.IMAGES)
.and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory());
public String sanitize(String html) {
boolean disableSanitize =
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
return disableSanitize ? html : POLICY.sanitize(html);
public static String sanitize(String html) {
String htmlAfter = POLICY.sanitize(html);
return htmlAfter;
}
}
@@ -133,9 +133,9 @@ public class EmlToPdf {
EmlToPdfRequest request,
byte[] emlBytes,
String fileName,
boolean disableSanitize,
stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager,
CustomHtmlSanitizer customHtmlSanitizer)
TempFileManager tempFileManager)
throws IOException, InterruptedException {
validateEmlInput(emlBytes);
@@ -155,11 +155,7 @@ public class EmlToPdf {
// Convert HTML to PDF
byte[] pdfBytes =
convertHtmlToPdf(
weasyprintPath,
request,
htmlContent,
tempFileManager,
customHtmlSanitizer);
weasyprintPath, request, htmlContent, disableSanitize, tempFileManager);
// Attach files if available and requested
if (shouldAttachFiles(emailContent, request)) {
@@ -200,8 +196,8 @@ public class EmlToPdf {
String weasyprintPath,
EmlToPdfRequest request,
String htmlContent,
TempFileManager tempFileManager,
CustomHtmlSanitizer customHtmlSanitizer)
boolean disableSanitize,
TempFileManager tempFileManager)
throws IOException, InterruptedException {
HTMLToPdfRequest htmlRequest = createHtmlRequest(request);
@@ -212,8 +208,8 @@ public class EmlToPdf {
htmlRequest,
htmlContent.getBytes(StandardCharsets.UTF_8),
"email.html",
tempFileManager,
customHtmlSanitizer);
disableSanitize,
tempFileManager);
} catch (IOException | InterruptedException e) {
log.warn("Initial HTML to PDF conversion failed, trying with simplified HTML");
String simplifiedHtml = simplifyHtmlContent(htmlContent);
@@ -222,8 +218,8 @@ public class EmlToPdf {
htmlRequest,
simplifiedHtml.getBytes(StandardCharsets.UTF_8),
"email.html",
tempFileManager,
customHtmlSanitizer);
disableSanitize,
tempFileManager);
}
}
@@ -26,8 +26,8 @@ public class FileToPdf {
HTMLToPdfRequest request,
byte[] fileBytes,
String fileName,
TempFileManager tempFileManager,
CustomHtmlSanitizer customHtmlSanitizer)
boolean disableSanitize,
TempFileManager tempFileManager)
throws IOException, InterruptedException {
try (TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) {
@@ -39,15 +39,14 @@ public class FileToPdf {
if (fileName.toLowerCase().endsWith(".html")) {
String sanitizedHtml =
sanitizeHtmlContent(
new String(fileBytes, StandardCharsets.UTF_8),
customHtmlSanitizer);
new String(fileBytes, StandardCharsets.UTF_8), disableSanitize);
Files.write(
tempInputFile.getPath(),
sanitizedHtml.getBytes(StandardCharsets.UTF_8));
} else if (fileName.toLowerCase().endsWith(".zip")) {
Files.write(tempInputFile.getPath(), fileBytes);
sanitizeHtmlFilesInZip(
tempInputFile.getPath(), tempFileManager, customHtmlSanitizer);
tempInputFile.getPath(), disableSanitize, tempFileManager);
} else {
throw ExceptionUtils.createHtmlFileRequiredException();
}
@@ -79,15 +78,12 @@ public class FileToPdf {
} // tempOutputFile auto-closed
}
private static String sanitizeHtmlContent(
String htmlContent, CustomHtmlSanitizer customHtmlSanitizer) {
return customHtmlSanitizer.sanitize(htmlContent);
private static String sanitizeHtmlContent(String htmlContent, boolean disableSanitize) {
return (!disableSanitize) ? CustomHtmlSanitizer.sanitize(htmlContent) : htmlContent;
}
private static void sanitizeHtmlFilesInZip(
Path zipFilePath,
TempFileManager tempFileManager,
CustomHtmlSanitizer customHtmlSanitizer)
Path zipFilePath, boolean disableSanitize, TempFileManager tempFileManager)
throws IOException {
try (TempDirectory tempUnzippedDir = new TempDirectory(tempFileManager)) {
try (ZipInputStream zipIn =
@@ -103,8 +99,7 @@ public class FileToPdf {
|| entry.getName().toLowerCase().endsWith(".htm")) {
String content =
new String(zipIn.readAllBytes(), StandardCharsets.UTF_8);
String sanitizedContent =
sanitizeHtmlContent(content, customHtmlSanitizer);
String sanitizedContent = sanitizeHtmlContent(content, disableSanitize);
Files.write(
filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8));
} else {
@@ -14,10 +14,8 @@ import java.util.Arrays;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.io.support.ResourcePatternUtils;
@@ -35,17 +33,6 @@ import stirling.software.common.configuration.InstallationPathConfig;
@Slf4j
public class GeneralUtils {
private static final Set<String> DEFAULT_VALID_SCRIPTS =
Set.of("png_to_webp.py", "split_photos.py");
private static final Set<String> DEFAULT_VALID_PIPELINE =
Set.of(
"OCR images.json",
"Prepare-pdfs-for-email.json",
"split-rotate-auto-rename.json");
private static final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
private static final String PYTHON_SCRIPTS_DIR = "python";
public static File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException {
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
if (customTempDir == null || customTempDir.isEmpty()) {
@@ -455,124 +442,6 @@ public class GeneralUtils {
}
}
/**
* Extracts the default pipeline configurations from the classpath to the installation path.
* Creates directories if needed and copies default JSON files.
*
* <p>Existing files will be overwritten atomically (when supported). In case of unsupported
* atomic moves, falls back to non-atomic replace.
*
* @throws IOException if an I/O error occurs during file operations
*/
public static void extractPipeline() throws IOException {
Path pipelineDir =
Paths.get(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR);
Files.createDirectories(pipelineDir);
for (String name : DEFAULT_VALID_PIPELINE) {
if (!Paths.get(name).getFileName().toString().equals(name)) {
log.error("Invalid pipeline file name: {}", name);
throw new IllegalArgumentException("Invalid pipeline file name: " + name);
}
Path target = pipelineDir.resolve(name);
ClassPathResource res =
new ClassPathResource(
"static/pipeline/" + DEFAULT_WEBUI_CONFIGS_DIR + "/" + name);
if (!res.exists()) {
log.error("Resource not found: {}", res.getPath());
throw new IOException("Resource not found: " + res.getPath());
}
copyResourceToFile(res, target);
}
}
/**
* Extracts the specified Python script from the classpath to the installation path. Validates
* name and copies file atomically when possible, overwriting existing.
*
* <p>Existing files will be overwritten atomically (when supported).
*
* @param scriptName the name of the script to extract
* @return the path to the extracted script
* @throws IllegalArgumentException if the script name is invalid or not allowed
* @throws IOException if an I/O error occurs
*/
public static Path extractScript(String scriptName) throws IOException {
// Validate input
if (scriptName == null || scriptName.trim().isEmpty()) {
throw new IllegalArgumentException("scriptName must not be null or empty");
}
if (scriptName.contains("..") || scriptName.contains("/")) {
throw new IllegalArgumentException(
"scriptName must not contain path traversal characters");
}
if (!Paths.get(scriptName).getFileName().toString().equals(scriptName)) {
throw new IllegalArgumentException(
"scriptName must not contain path traversal characters");
}
if (!DEFAULT_VALID_SCRIPTS.contains(scriptName)) {
throw new IllegalArgumentException(
"scriptName must be either 'png_to_webp.py' or 'split_photos.py'");
}
Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR);
Files.createDirectories(scriptsDir);
Path target = scriptsDir.resolve(scriptName);
ClassPathResource res =
new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName);
if (!res.exists()) {
log.error("Resource not found: {}", res.getPath());
throw new IOException("Resource not found: " + res.getPath());
}
copyResourceToFile(res, target);
return target;
}
/**
* Copies a resource from the classpath to a specified target file.
*
* @param resource the ClassPathResource to copy
* @param target the target Path where the resource will be copied
* @throws IOException if an I/O error occurs during the copy operation
*/
private static void copyResourceToFile(ClassPathResource resource, Path target)
throws IOException {
Path dir = target.getParent();
Path tmp = Files.createTempFile(dir, target.getFileName().toString(), ".tmp");
try (InputStream in = resource.getInputStream()) {
Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING);
try {
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
log.warn(
"Atomic move not supported, falling back to non-atomic move for {}",
target,
e);
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
}
} catch (FileAlreadyExistsException e) {
log.debug("File already exists at {}, attempting to replace it.", target);
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
} catch (AccessDeniedException e) {
log.error("Access denied while attempting to copy resource to {}", target, e);
throw e;
} catch (FileSystemException e) {
log.error("File system error occurred while copying resource to {}", target, e);
throw e;
} catch (IOException e) {
log.error("Failed to copy resource to {}", target, e);
throw e;
} finally {
try {
Files.deleteIfExists(tmp);
} catch (IOException e) {
log.warn("Failed to delete temporary file {}", tmp, e);
}
}
}
public static boolean isVersionHigher(String currentVersion, String compareVersion) {
if (currentVersion == null || compareVersion == null) {
return false;
@@ -7,19 +7,24 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import java.util.Arrays;
import java.util.function.Supplier;
import org.aspectj.lang.ProceedingJoinPoint;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
@@ -40,44 +45,62 @@ class AutoJobPostMappingIntegrationTest {
private AutoJobAspect autoJobAspect;
@Mock private JobExecutorService jobExecutorService;
@Mock
private JobExecutorService jobExecutorService;
@Mock private HttpServletRequest request;
@Mock
private HttpServletRequest request;
@Mock private FileOrUploadService fileOrUploadService;
@Mock
private FileOrUploadService fileOrUploadService;
@Mock private FileStorage fileStorage;
@Mock
private FileStorage fileStorage;
@Mock private ResourceMonitor resourceMonitor;
@Mock private JobQueue jobQueue;
@Mock
private ResourceMonitor resourceMonitor;
@Mock
private JobQueue jobQueue;
@BeforeEach
void setUp() {
autoJobAspect =
new AutoJobAspect(jobExecutorService, request, fileOrUploadService, fileStorage);
autoJobAspect = new AutoJobAspect(
jobExecutorService,
request,
fileOrUploadService,
fileStorage
);
}
@Mock private ProceedingJoinPoint joinPoint;
@Mock
private ProceedingJoinPoint joinPoint;
@Mock private AutoJobPostMapping autoJobPostMapping;
@Mock
private AutoJobPostMapping autoJobPostMapping;
@Captor private ArgumentCaptor<Supplier<Object>> workCaptor;
@Captor
private ArgumentCaptor<Supplier<Object>> workCaptor;
@Captor private ArgumentCaptor<Boolean> asyncCaptor;
@Captor
private ArgumentCaptor<Boolean> asyncCaptor;
@Captor private ArgumentCaptor<Long> timeoutCaptor;
@Captor
private ArgumentCaptor<Long> timeoutCaptor;
@Captor private ArgumentCaptor<Boolean> queueableCaptor;
@Captor
private ArgumentCaptor<Boolean> queueableCaptor;
@Captor private ArgumentCaptor<Integer> resourceWeightCaptor;
@Captor
private ArgumentCaptor<Integer> resourceWeightCaptor;
@Test
void shouldExecuteWithCustomParameters() throws Throwable {
// Given
PDFFile pdfFile = new PDFFile();
pdfFile.setFileId("test-file-id");
Object[] args = new Object[] {pdfFile};
Object[] args = new Object[] { pdfFile };
when(joinPoint.getArgs()).thenReturn(args);
when(request.getParameter("async")).thenReturn("true");
@@ -90,8 +113,9 @@ class AutoJobPostMappingIntegrationTest {
MultipartFile mockFile = mock(MultipartFile.class);
when(fileStorage.retrieveFile("test-file-id")).thenReturn(mockFile);
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenReturn(ResponseEntity.ok("success"));
// When
@@ -100,13 +124,12 @@ class AutoJobPostMappingIntegrationTest {
// Then
assertEquals(ResponseEntity.ok("success"), result);
verify(jobExecutorService)
.runJobGeneric(
asyncCaptor.capture(),
workCaptor.capture(),
timeoutCaptor.capture(),
queueableCaptor.capture(),
resourceWeightCaptor.capture());
verify(jobExecutorService).runJobGeneric(
asyncCaptor.capture(),
workCaptor.capture(),
timeoutCaptor.capture(),
queueableCaptor.capture(),
resourceWeightCaptor.capture());
assertTrue(asyncCaptor.getValue(), "Async should be true");
assertEquals(60000L, timeoutCaptor.getValue(), "Timeout should be 60000ms");
@@ -135,12 +158,11 @@ class AutoJobPostMappingIntegrationTest {
// Mock jobExecutorService to execute the work immediately
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenAnswer(
invocation -> {
Supplier<Object> work = invocation.getArgument(1);
return work.get();
});
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenAnswer(invocation -> {
Supplier<Object> work = invocation.getArgument(1);
return work.get();
});
// When
Object result = autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
@@ -157,7 +179,7 @@ class AutoJobPostMappingIntegrationTest {
// Given
PDFFile pdfFile = new PDFFile();
pdfFile.setFileInput(mock(MultipartFile.class));
Object[] args = new Object[] {pdfFile};
Object[] args = new Object[] { pdfFile };
when(joinPoint.getArgs()).thenReturn(args);
when(request.getParameter("async")).thenReturn("true");
@@ -168,16 +190,14 @@ class AutoJobPostMappingIntegrationTest {
// Mock job executor to return a successful response
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenReturn(ResponseEntity.ok("success"));
// When
autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
// Then
assertEquals(
"stored-file-id",
pdfFile.getFileId(),
assertEquals("stored-file-id", pdfFile.getFileId(),
"FileId should be set to the stored file id");
assertNotNull(pdfFile.getFileInput(), "FileInput should be replaced with persistent file");
@@ -1,9 +1,10 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.AdditionalAnswers.*;
import static org.mockito.Mockito.*;
import static org.mockito.AdditionalAnswers.*;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -20,11 +21,14 @@ import org.springframework.web.multipart.MultipartFile;
class FileStorageTest {
@TempDir Path tempDir;
@TempDir
Path tempDir;
@Mock private FileOrUploadService fileOrUploadService;
@Mock
private FileOrUploadService fileOrUploadService;
@InjectMocks private FileStorage fileStorage;
@InjectMocks
private FileStorage fileStorage;
private MultipartFile mockFile;
@@ -46,14 +50,11 @@ class FileStorageTest {
when(mockFile.getBytes()).thenReturn(fileContent);
// Set up mock to handle transferTo by writing the file
doAnswer(
invocation -> {
java.io.File file = invocation.getArgument(0);
Files.write(file.toPath(), fileContent);
return null;
})
.when(mockFile)
.transferTo(any(java.io.File.class));
doAnswer(invocation -> {
java.io.File file = invocation.getArgument(0);
Files.write(file.toPath(), fileContent);
return null;
}).when(mockFile).transferTo(any(java.io.File.class));
// Act
String fileId = fileStorage.storeFile(mockFile);
@@ -89,7 +90,7 @@ class FileStorageTest {
MultipartFile expectedFile = mock(MultipartFile.class);
when(fileOrUploadService.toMockMultipartFile(eq(fileId), eq(fileContent)))
.thenReturn(expectedFile);
.thenReturn(expectedFile);
// Act
MultipartFile result = fileStorage.retrieveFile(fileId);
@@ -4,9 +4,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -25,9 +30,11 @@ import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.model.job.JobProgress;
import stirling.software.common.model.job.JobResponse;
@ExtendWith(MockitoExtension.class)
@@ -35,31 +42,36 @@ class JobExecutorServiceTest {
private JobExecutorService jobExecutorService;
@Mock private TaskManager taskManager;
@Mock
private TaskManager taskManager;
@Mock private FileStorage fileStorage;
@Mock
private FileStorage fileStorage;
@Mock private HttpServletRequest request;
@Mock
private HttpServletRequest request;
@Mock private ResourceMonitor resourceMonitor;
@Mock
private ResourceMonitor resourceMonitor;
@Mock private JobQueue jobQueue;
@Mock
private JobQueue jobQueue;
@Captor private ArgumentCaptor<String> jobIdCaptor;
@Captor
private ArgumentCaptor<String> jobIdCaptor;
@BeforeEach
void setUp() {
// Initialize the service manually with all its dependencies
jobExecutorService =
new JobExecutorService(
taskManager,
fileStorage,
request,
resourceMonitor,
jobQueue,
30000L, // asyncRequestTimeoutMs
"30m" // sessionTimeout
);
jobExecutorService = new JobExecutorService(
taskManager,
fileStorage,
request,
resourceMonitor,
jobQueue,
30000L, // asyncRequestTimeoutMs
"30m" // sessionTimeout
);
}
@Test
@@ -97,13 +109,13 @@ class JobExecutorServiceTest {
verify(taskManager).createTask(jobIdCaptor.capture());
}
@Test
void shouldHandleSyncJobError() {
// Given
Supplier<Object> work =
() -> {
throw new RuntimeException("Test error");
};
Supplier<Object> work = () -> {
throw new RuntimeException("Test error");
};
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(false, work);
@@ -129,7 +141,8 @@ class JobExecutorServiceTest {
when(jobQueue.queueJob(anyString(), eq(80), any(), anyLong())).thenReturn(future);
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(true, work, 5000, true, 80);
ResponseEntity<?> response = jobExecutorService.runJobGeneric(
true, work, 5000, true, 80);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
@@ -147,9 +160,8 @@ class JobExecutorServiceTest {
long customTimeout = 60000L;
// Use reflection to access the private executeWithTimeout method
java.lang.reflect.Method executeMethod =
JobExecutorService.class.getDeclaredMethod(
"executeWithTimeout", Supplier.class, long.class);
java.lang.reflect.Method executeMethod = JobExecutorService.class
.getDeclaredMethod("executeWithTimeout", Supplier.class, long.class);
executeMethod.setAccessible(true);
// Create a spy on the JobExecutorService to verify method calls
@@ -165,21 +177,19 @@ class JobExecutorServiceTest {
@Test
void shouldHandleTimeout() throws Exception {
// Given
Supplier<Object> work =
() -> {
try {
Thread.sleep(100); // Simulate long-running job
return "test-result";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
};
Supplier<Object> work = () -> {
try {
Thread.sleep(100); // Simulate long-running job
return "test-result";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
};
// Use reflection to access the private executeWithTimeout method
java.lang.reflect.Method executeMethod =
JobExecutorService.class.getDeclaredMethod(
"executeWithTimeout", Supplier.class, long.class);
java.lang.reflect.Method executeMethod = JobExecutorService.class
.getDeclaredMethod("executeWithTimeout", Supplier.class, long.class);
executeMethod.setAccessible(true);
// When/Then
@@ -1,8 +1,10 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
@@ -15,6 +17,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.job.JobProgress;
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
@ExtendWith(MockitoExtension.class)
@@ -22,17 +25,16 @@ class JobQueueTest {
private JobQueue jobQueue;
@Mock private ResourceMonitor resourceMonitor;
@Mock
private ResourceMonitor resourceMonitor;
private final AtomicReference<ResourceStatus> statusRef =
new AtomicReference<>(ResourceStatus.OK);
private final AtomicReference<ResourceStatus> statusRef = new AtomicReference<>(ResourceStatus.OK);
@BeforeEach
void setUp() {
// Mark stubbing as lenient to avoid UnnecessaryStubbingException
lenient()
.when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt()))
.thenReturn(10);
lenient().when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())).thenReturn(10);
lenient().when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef);
// Initialize JobQueue with mocked ResourceMonitor
@@ -48,6 +50,7 @@ class JobQueueTest {
jobQueue.queueJob(jobId, resourceWeight, work, timeoutMs);
assertTrue(jobQueue.isJobQueued(jobId));
assertEquals(1, jobQueue.getTotalQueuedJobs());
}
@@ -1,10 +1,14 @@
package stirling.software.common.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.lang.management.OperatingSystemMXBean;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicReference;
@@ -26,19 +30,20 @@ import stirling.software.common.service.ResourceMonitor.ResourceStatus;
@ExtendWith(MockitoExtension.class)
class ResourceMonitorTest {
@InjectMocks private ResourceMonitor resourceMonitor;
@InjectMocks
private ResourceMonitor resourceMonitor;
@Mock private OperatingSystemMXBean osMXBean;
@Mock
private OperatingSystemMXBean osMXBean;
@Mock private MemoryMXBean memoryMXBean;
@Mock
private MemoryMXBean memoryMXBean;
@Spy
private AtomicReference<ResourceStatus> currentStatus =
new AtomicReference<>(ResourceStatus.OK);
private AtomicReference<ResourceStatus> currentStatus = new AtomicReference<>(ResourceStatus.OK);
@Spy
private AtomicReference<ResourceMetrics> latestMetrics =
new AtomicReference<>(new ResourceMetrics());
private AtomicReference<ResourceMetrics> latestMetrics = new AtomicReference<>(new ResourceMetrics());
@BeforeEach
void setUp() {
@@ -87,26 +92,23 @@ class ResourceMonitorTest {
assertEquals(3, capacity, "With CRITICAL status, capacity should be reduced to 30%");
// Test minimum capacity enforcement
assertEquals(
minCapacity,
resourceMonitor.calculateDynamicQueueCapacity(1, minCapacity),
assertEquals(minCapacity, resourceMonitor.calculateDynamicQueueCapacity(1, minCapacity),
"Should never go below minimum capacity");
}
@ParameterizedTest
@CsvSource({
"10, OK, false", // Light job, OK status
"10, OK, false", // Light job, OK status
"10, WARNING, false", // Light job, WARNING status
"10, CRITICAL, true", // Light job, CRITICAL status
"30, OK, false", // Medium job, OK status
"30, WARNING, true", // Medium job, WARNING status
"30, OK, false", // Medium job, OK status
"30, WARNING, true", // Medium job, WARNING status
"30, CRITICAL, true", // Medium job, CRITICAL status
"80, OK, true", // Heavy job, OK status
"80, WARNING, true", // Heavy job, WARNING status
"80, CRITICAL, true" // Heavy job, CRITICAL status
"80, OK, true", // Heavy job, OK status
"80, WARNING, true", // Heavy job, WARNING status
"80, CRITICAL, true" // Heavy job, CRITICAL status
})
void shouldQueueJobBasedOnWeightAndStatus(
int weight, ResourceStatus status, boolean shouldQueue) {
void shouldQueueJobBasedOnWeightAndStatus(int weight, ResourceStatus status, boolean shouldQueue) {
// Given
currentStatus.set(status);
@@ -114,11 +116,8 @@ class ResourceMonitorTest {
boolean result = resourceMonitor.shouldQueueJob(weight);
// Then
assertEquals(
shouldQueue,
result,
String.format(
"For weight %d and status %s, shouldQueue should be %s",
assertEquals(shouldQueue, result,
String.format("For weight %d and status %s, shouldQueue should be %s",
weight, status, shouldQueue));
}
@@ -132,9 +131,7 @@ class ResourceMonitorTest {
ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, now);
// When/Then
assertTrue(
staleMetrics.isStale(5000),
"Metrics from 6 seconds ago should be stale with 5s threshold");
assertTrue(staleMetrics.isStale(5000), "Metrics from 6 seconds ago should be stale with 5s threshold");
assertFalse(freshMetrics.isStale(5000), "Fresh metrics should not be stale");
}
}
@@ -6,6 +6,7 @@ import static org.mockito.Mockito.*;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -21,9 +22,11 @@ import stirling.software.common.model.job.ResultFile;
class TaskManagerTest {
@Mock private FileStorage fileStorage;
@Mock
private FileStorage fileStorage;
@InjectMocks private TaskManager taskManager;
@InjectMocks
private TaskManager taskManager;
private AutoCloseable closeable;
@@ -231,20 +234,18 @@ class TaskManagerTest {
ReflectionTestUtils.setField(oldJob, "complete", true);
// Create a ResultFile and set it using the new approach
ResultFile resultFile =
ResultFile.builder()
.fileId("file-id")
.fileName("test.pdf")
.contentType("application/pdf")
.fileSize(1024L)
.build();
ResultFile resultFile = ResultFile.builder()
.fileId("file-id")
.fileName("test.pdf")
.contentType("application/pdf")
.fileSize(1024L)
.build();
ReflectionTestUtils.setField(oldJob, "resultFiles", java.util.List.of(resultFile));
when(fileStorage.deleteFile("file-id")).thenReturn(true);
// Obtain access to the private jobResults map
Map<String, JobResult> jobResultsMap =
(Map<String, JobResult>) ReflectionTestUtils.getField(taskManager, "jobResults");
Map<String, JobResult> jobResultsMap = (Map<String, JobResult>) ReflectionTestUtils.getField(taskManager, "jobResults");
// 3. Create an active job
String activeJobId = "active-job";
@@ -12,6 +12,7 @@ import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.stream.Stream;
@@ -29,22 +30,31 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/** Tests for the TempFileCleanupService, focusing on its pattern-matching and cleanup logic. */
/**
* Tests for the TempFileCleanupService, focusing on its pattern-matching and cleanup logic.
*/
public class TempFileCleanupServiceTest {
@TempDir Path tempDir;
@TempDir
Path tempDir;
@Mock private TempFileRegistry registry;
@Mock
private TempFileRegistry registry;
@Mock private TempFileManager tempFileManager;
@Mock
private TempFileManager tempFileManager;
@Mock private ApplicationProperties applicationProperties;
@Mock
private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.System system;
@Mock
private ApplicationProperties.System system;
@Mock private ApplicationProperties.TempFileManagement tempFileManagement;
@Mock
private ApplicationProperties.TempFileManagement tempFileManagement;
@InjectMocks private TempFileCleanupService cleanupService;
@InjectMocks
private TempFileCleanupService cleanupService;
private Path systemTempDir;
private Path customTempDir;
@@ -114,8 +124,7 @@ public class TempFileCleanupServiceTest {
// Files that should be preserved
Path jettyFile1 = Files.createFile(systemTempDir.resolve("jetty-123.tmp"));
Path jettyFile2 =
Files.createFile(systemTempDir.resolve("something-with-jetty-inside.tmp"));
Path jettyFile2 = Files.createFile(systemTempDir.resolve("something-with-jetty-inside.tmp"));
Path regularFile = Files.createFile(systemTempDir.resolve("important.txt"));
// Create a nested directory with temp files
@@ -134,29 +143,19 @@ public class TempFileCleanupServiceTest {
// Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Mock Files.list for each directory we'll process
mockedFiles
.when(() -> Files.list(eq(systemTempDir)))
.thenReturn(
Stream.of(
ourTempFile1,
ourTempFile2,
oldTempFile,
sysTempFile1,
jettyFile1,
jettyFile2,
regularFile,
emptyFile,
nestedDir));
mockedFiles.when(() -> Files.list(eq(systemTempDir)))
.thenReturn(Stream.of(
ourTempFile1, ourTempFile2, oldTempFile, sysTempFile1,
jettyFile1, jettyFile2, regularFile, emptyFile, nestedDir));
mockedFiles
.when(() -> Files.list(eq(customTempDir)))
mockedFiles.when(() -> Files.list(eq(customTempDir)))
.thenReturn(Stream.of(ourTempFile3, ourTempFile4, sysTempFile2, sysTempFile3));
mockedFiles
.when(() -> Files.list(eq(libreOfficeTempDir)))
mockedFiles.when(() -> Files.list(eq(libreOfficeTempDir)))
.thenReturn(Stream.of(ourTempFile5));
mockedFiles.when(() -> Files.list(eq(nestedDir))).thenReturn(Stream.of(nestedTempFile));
mockedFiles.when(() -> Files.list(eq(nestedDir)))
.thenReturn(Stream.of(nestedTempFile));
// Configure Files.isDirectory for each path
mockedFiles.when(() -> Files.isDirectory(eq(nestedDir))).thenReturn(true);
@@ -166,59 +165,48 @@ public class TempFileCleanupServiceTest {
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
// Configure Files.getLastModifiedTime to return different times based on file names
mockedFiles
.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenAnswer(
invocation -> {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
// For files with "old" in the name, return a timestamp older than
// maxAgeMillis
if (fileName.contains("old")) {
return FileTime.fromMillis(
System.currentTimeMillis() - 5000000);
}
// For empty.tmp file, return a timestamp older than 5 minutes (for
// empty file test)
else if (fileName.equals("empty.tmp")) {
return FileTime.fromMillis(
System.currentTimeMillis() - 6 * 60 * 1000);
}
// For all other files, return a recent timestamp
else {
return FileTime.fromMillis(
System.currentTimeMillis() - 60000); // 1 minute ago
}
});
// For files with "old" in the name, return a timestamp older than maxAgeMillis
if (fileName.contains("old")) {
return FileTime.fromMillis(System.currentTimeMillis() - 5000000);
}
// For empty.tmp file, return a timestamp older than 5 minutes (for empty file test)
else if (fileName.equals("empty.tmp")) {
return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000);
}
// For all other files, return a recent timestamp
else {
return FileTime.fromMillis(System.currentTimeMillis() - 60000); // 1 minute ago
}
});
// Configure Files.size to return different sizes based on file names
mockedFiles
.when(() -> Files.size(any(Path.class)))
.thenAnswer(
invocation -> {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
mockedFiles.when(() -> Files.size(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
// Return 0 bytes for the empty file
if (fileName.equals("empty.tmp")) {
return 0L;
}
// Return normal size for all other files
else {
return 1024L; // 1 KB
}
});
// Return 0 bytes for the empty file
if (fileName.equals("empty.tmp")) {
return 0L;
}
// Return normal size for all other files
else {
return 1024L; // 1 KB
}
});
// For deleteIfExists, track which files would be deleted
mockedFiles
.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(
invocation -> {
Path path = invocation.getArgument(0);
deletedFiles.add(path);
return true;
});
mockedFiles.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
deletedFiles.add(path);
return true;
});
// Act - set containerMode to false for this test
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
@@ -230,33 +218,20 @@ public class TempFileCleanupServiceTest {
assertTrue(deletedFiles.contains(emptyFile), "Empty file should be deleted");
// Regular temp files should not be deleted because they're too new
assertFalse(
deletedFiles.contains(ourTempFile1), "Recent temp file should be preserved");
assertFalse(
deletedFiles.contains(ourTempFile2), "Recent temp file should be preserved");
assertFalse(
deletedFiles.contains(ourTempFile3), "Recent temp file should be preserved");
assertFalse(
deletedFiles.contains(ourTempFile4), "Recent temp file should be preserved");
assertFalse(
deletedFiles.contains(ourTempFile5), "Recent temp file should be preserved");
assertFalse(deletedFiles.contains(ourTempFile1), "Recent temp file should be preserved");
assertFalse(deletedFiles.contains(ourTempFile2), "Recent temp file should be preserved");
assertFalse(deletedFiles.contains(ourTempFile3), "Recent temp file should be preserved");
assertFalse(deletedFiles.contains(ourTempFile4), "Recent temp file should be preserved");
assertFalse(deletedFiles.contains(ourTempFile5), "Recent temp file should be preserved");
// System temp files should not be deleted in non-container mode
assertFalse(
deletedFiles.contains(sysTempFile1),
"System temp file should be preserved in non-container mode");
assertFalse(
deletedFiles.contains(sysTempFile2),
"System temp file should be preserved in non-container mode");
assertFalse(
deletedFiles.contains(sysTempFile3),
"System temp file should be preserved in non-container mode");
assertFalse(deletedFiles.contains(sysTempFile1), "System temp file should be preserved in non-container mode");
assertFalse(deletedFiles.contains(sysTempFile2), "System temp file should be preserved in non-container mode");
assertFalse(deletedFiles.contains(sysTempFile3), "System temp file should be preserved in non-container mode");
// Jetty files and regular files should never be deleted
assertFalse(deletedFiles.contains(jettyFile1), "Jetty file should be preserved");
assertFalse(
deletedFiles.contains(jettyFile2),
"File with jetty in name should be preserved");
assertFalse(deletedFiles.contains(jettyFile2), "File with jetty in name should be preserved");
assertFalse(deletedFiles.contains(regularFile), "Regular file should be preserved");
}
}
@@ -277,8 +252,7 @@ public class TempFileCleanupServiceTest {
// Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Mock Files.list for systemTempDir
mockedFiles
.when(() -> Files.list(eq(systemTempDir)))
mockedFiles.when(() -> Files.list(eq(systemTempDir)))
.thenReturn(Stream.of(ourTempFile, sysTempFile, regularFile));
// Configure Files.isDirectory
@@ -288,37 +262,28 @@ public class TempFileCleanupServiceTest {
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
// Configure Files.getLastModifiedTime to return recent timestamps
mockedFiles
.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenReturn(
FileTime.fromMillis(
System.currentTimeMillis() - 60000)); // 1 minute ago
mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenReturn(FileTime.fromMillis(System.currentTimeMillis() - 60000)); // 1 minute ago
// Configure Files.size to return normal size
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L); // 1 KB
mockedFiles.when(() -> Files.size(any(Path.class)))
.thenReturn(1024L); // 1 KB
// For deleteIfExists, track which files would be deleted
mockedFiles
.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(
invocation -> {
Path path = invocation.getArgument(0);
deletedFiles.add(path);
return true;
});
mockedFiles.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
deletedFiles.add(path);
return true;
});
// Act - set containerMode to true and maxAgeMillis to 0 for container startup cleanup
invokeCleanupDirectoryStreaming(systemTempDir, true, 0, 0);
// Assert - In container mode, both our temp files and system temp files should be
// deleted
// Assert - In container mode, both our temp files and system temp files should be deleted
// regardless of age (when maxAgeMillis is 0)
assertTrue(
deletedFiles.contains(ourTempFile),
"Our temp file should be deleted in container mode");
assertTrue(
deletedFiles.contains(sysTempFile),
"System temp file should be deleted in container mode");
assertTrue(deletedFiles.contains(ourTempFile), "Our temp file should be deleted in container mode");
assertTrue(deletedFiles.contains(sysTempFile), "System temp file should be deleted in container mode");
assertFalse(deletedFiles.contains(regularFile), "Regular file should be preserved");
}
}
@@ -338,8 +303,7 @@ public class TempFileCleanupServiceTest {
// Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Mock Files.list for systemTempDir
mockedFiles
.when(() -> Files.list(eq(systemTempDir)))
mockedFiles.when(() -> Files.list(eq(systemTempDir)))
.thenReturn(Stream.of(emptyFile, recentEmptyFile));
// Configure Files.isDirectory
@@ -349,46 +313,39 @@ public class TempFileCleanupServiceTest {
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
// Configure Files.getLastModifiedTime to return different times based on file names
mockedFiles
.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenAnswer(
invocation -> {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
if (fileName.equals("empty.tmp")) {
// More than 5 minutes old
return FileTime.fromMillis(
System.currentTimeMillis() - 6 * 60 * 1000);
} else {
// Less than 5 minutes old
return FileTime.fromMillis(
System.currentTimeMillis() - 2 * 60 * 1000);
}
});
if (fileName.equals("empty.tmp")) {
// More than 5 minutes old
return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000);
} else {
// Less than 5 minutes old
return FileTime.fromMillis(System.currentTimeMillis() - 2 * 60 * 1000);
}
});
// Configure Files.size to return 0 for empty files
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(0L);
mockedFiles.when(() -> Files.size(any(Path.class)))
.thenReturn(0L);
// For deleteIfExists, track which files would be deleted
mockedFiles
.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(
invocation -> {
Path path = invocation.getArgument(0);
deletedFiles.add(path);
return true;
});
mockedFiles.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
deletedFiles.add(path);
return true;
});
// Act
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
// Assert
assertTrue(
deletedFiles.contains(emptyFile),
assertTrue(deletedFiles.contains(emptyFile),
"Empty file older than 5 minutes should be deleted");
assertFalse(
deletedFiles.contains(recentEmptyFile),
assertFalse(deletedFiles.contains(recentEmptyFile),
"Empty file newer than 5 minutes should not be deleted");
}
}
@@ -413,13 +370,17 @@ public class TempFileCleanupServiceTest {
// Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Mock Files.list for each directory
mockedFiles.when(() -> Files.list(eq(systemTempDir))).thenReturn(Stream.of(dir1));
mockedFiles.when(() -> Files.list(eq(systemTempDir)))
.thenReturn(Stream.of(dir1));
mockedFiles.when(() -> Files.list(eq(dir1))).thenReturn(Stream.of(tempFile1, dir2));
mockedFiles.when(() -> Files.list(eq(dir1)))
.thenReturn(Stream.of(tempFile1, dir2));
mockedFiles.when(() -> Files.list(eq(dir2))).thenReturn(Stream.of(tempFile2, dir3));
mockedFiles.when(() -> Files.list(eq(dir2)))
.thenReturn(Stream.of(tempFile2, dir3));
mockedFiles.when(() -> Files.list(eq(dir3))).thenReturn(Stream.of(tempFile3));
mockedFiles.when(() -> Files.list(eq(dir3)))
.thenReturn(Stream.of(tempFile3));
// Configure Files.isDirectory for each path
mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true);
@@ -433,35 +394,31 @@ public class TempFileCleanupServiceTest {
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
// Configure Files.getLastModifiedTime to return different times based on file names
mockedFiles
.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenAnswer(
invocation -> {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
if (fileName.contains("old")) {
// Old file
return FileTime.fromMillis(
System.currentTimeMillis() - 5000000);
} else {
// Recent file
return FileTime.fromMillis(System.currentTimeMillis() - 60000);
}
});
if (fileName.contains("old")) {
// Old file
return FileTime.fromMillis(System.currentTimeMillis() - 5000000);
} else {
// Recent file
return FileTime.fromMillis(System.currentTimeMillis() - 60000);
}
});
// Configure Files.size to return normal size
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L);
mockedFiles.when(() -> Files.size(any(Path.class)))
.thenReturn(1024L);
// For deleteIfExists, track which files would be deleted
mockedFiles
.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(
invocation -> {
Path path = invocation.getArgument(0);
deletedFiles.add(path);
return true;
});
mockedFiles.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(invocation -> {
Path path = invocation.getArgument(0);
deletedFiles.add(path);
return true;
});
// Act
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
@@ -473,15 +430,14 @@ public class TempFileCleanupServiceTest {
// Assert
assertFalse(deletedFiles.contains(tempFile1), "Recent temp file should be preserved");
assertFalse(deletedFiles.contains(tempFile2), "Recent temp file should be preserved");
assertTrue(
deletedFiles.contains(tempFile3),
"Old temp file in nested directory should be deleted");
assertTrue(deletedFiles.contains(tempFile3), "Old temp file in nested directory should be deleted");
}
}
/** Helper method to invoke the private cleanupDirectoryStreaming method using reflection */
private void invokeCleanupDirectoryStreaming(
Path directory, boolean containerMode, int depth, long maxAgeMillis)
/**
* Helper method to invoke the private cleanupDirectoryStreaming method using reflection
*/
private void invokeCleanupDirectoryStreaming(Path directory, boolean containerMode, int depth, long maxAgeMillis)
throws IOException {
try {
// Create a consumer that tracks deleted files
@@ -489,26 +445,13 @@ public class TempFileCleanupServiceTest {
Consumer<Path> deleteCallback = path -> deleteCount.incrementAndGet();
// Get the method with updated signature
var method =
TempFileCleanupService.class.getDeclaredMethod(
"cleanupDirectoryStreaming",
Path.class,
boolean.class,
int.class,
long.class,
boolean.class,
Consumer.class);
var method = TempFileCleanupService.class.getDeclaredMethod(
"cleanupDirectoryStreaming",
Path.class, boolean.class, int.class, long.class, boolean.class, Consumer.class);
method.setAccessible(true);
// Invoke the method with appropriate parameters
method.invoke(
cleanupService,
directory,
containerMode,
depth,
maxAgeMillis,
false,
deleteCallback);
method.invoke(cleanupService, directory, containerMode, depth, maxAgeMillis, false, deleteCallback);
} catch (Exception e) {
throw new RuntimeException("Error invoking cleanupDirectoryStreaming", e);
}
@@ -1,5 +1,14 @@
package stirling.software.common.util;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.Arrays;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -10,18 +19,6 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.Arrays;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
class CheckProgramInstallTest {
private MockedStatic<ProcessExecutor> mockProcessExecutor;
@@ -3,46 +3,21 @@ package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import stirling.software.common.service.SsrfProtectionService;
class CustomHtmlSanitizerTest {
private CustomHtmlSanitizer customHtmlSanitizer;
@BeforeEach
void setUp() {
SsrfProtectionService mockSsrfProtectionService = mock(SsrfProtectionService.class);
stirling.software.common.model.ApplicationProperties mockApplicationProperties =
mock(stirling.software.common.model.ApplicationProperties.class);
stirling.software.common.model.ApplicationProperties.System mockSystem =
mock(stirling.software.common.model.ApplicationProperties.System.class);
// Allow all URLs by default for basic tests
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(true);
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
when(mockSystem.getDisableSanitize()).thenReturn(false); // Enable sanitization for tests
customHtmlSanitizer =
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
}
@ParameterizedTest
@MethodSource("provideHtmlTestCases")
void testSanitizeHtml(String inputHtml, String[] expectedContainedTags) {
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(inputHtml);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(inputHtml);
// Assert
for (String tag : expectedContainedTags) {
@@ -83,7 +58,7 @@ class CustomHtmlSanitizerTest {
"<p style=\"color: blue; font-size: 16px; margin-top: 10px;\">Styled text</p>";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithStyles);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithStyles);
// Assert
// The OWASP HTML Sanitizer might filter some specific styles, so we only check that
@@ -100,7 +75,7 @@ class CustomHtmlSanitizerTest {
"<a href=\"https://example.com\" title=\"Example Site\">Example Link</a>";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithLink);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithLink);
// Assert
// The most important aspect is that the link content is preserved
@@ -122,7 +97,7 @@ class CustomHtmlSanitizerTest {
String htmlWithJsLink = "<a href=\"javascript:alert('XSS')\">Malicious Link</a>";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithJsLink);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithJsLink);
// Assert
assertFalse(sanitizedHtml.contains("javascript:"), "JavaScript URLs should be removed");
@@ -141,7 +116,7 @@ class CustomHtmlSanitizerTest {
+ "</table>";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithTable);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithTable);
// Assert
assertTrue(sanitizedHtml.contains("<table"), "Table should be preserved");
@@ -168,7 +143,7 @@ class CustomHtmlSanitizerTest {
"<img src=\"image.jpg\" alt=\"An image\" width=\"100\" height=\"100\">";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithImage);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithImage);
// Assert
assertTrue(sanitizedHtml.contains("<img"), "Image tag should be preserved");
@@ -185,7 +160,7 @@ class CustomHtmlSanitizerTest {
"<img src=\"data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9ImFsZXJ0KDEpIj48L3N2Zz4=\" alt=\"SVG with XSS\">";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithDataUrlImage);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithDataUrlImage);
// Assert
assertFalse(
@@ -200,7 +175,7 @@ class CustomHtmlSanitizerTest {
"<a href=\"#\" onclick=\"alert('XSS')\" onmouseover=\"alert('XSS')\">Click me</a>";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithJsEvent);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithJsEvent);
// Assert
assertFalse(
@@ -217,7 +192,7 @@ class CustomHtmlSanitizerTest {
String htmlWithScript = "<p>Safe content</p><script>alert('XSS');</script>";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithScript);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithScript);
// Assert
assertFalse(sanitizedHtml.contains("<script>"), "Script tags should be removed");
@@ -231,7 +206,7 @@ class CustomHtmlSanitizerTest {
String htmlWithNoscript = "<p>Safe content</p><noscript>JavaScript is disabled</noscript>";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithNoscript);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithNoscript);
// Assert
assertFalse(sanitizedHtml.contains("<noscript>"), "Noscript tags should be removed");
@@ -245,7 +220,7 @@ class CustomHtmlSanitizerTest {
String htmlWithIframe = "<p>Safe content</p><iframe src=\"https://example.com\"></iframe>";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithIframe);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithIframe);
// Assert
assertFalse(sanitizedHtml.contains("<iframe"), "Iframe tags should be removed");
@@ -262,7 +237,7 @@ class CustomHtmlSanitizerTest {
+ "<embed src=\"embed.swf\" type=\"application/x-shockwave-flash\">";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithObjects);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithObjects);
// Assert
assertFalse(sanitizedHtml.contains("<object"), "Object tags should be removed");
@@ -281,7 +256,7 @@ class CustomHtmlSanitizerTest {
+ "<link rel=\"stylesheet\" href=\"evil.css\">";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithMetaTags);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithMetaTags);
// Assert
assertFalse(sanitizedHtml.contains("<meta"), "Meta tags should be removed");
@@ -308,7 +283,7 @@ class CustomHtmlSanitizerTest {
+ "</div>";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(complexHtml);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(complexHtml);
// Assert
assertTrue(sanitizedHtml.contains("<div"), "Div should be preserved");
@@ -339,7 +314,7 @@ class CustomHtmlSanitizerTest {
@Test
void testSanitizeHandlesEmpty() {
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize("");
String sanitizedHtml = CustomHtmlSanitizer.sanitize("");
// Assert
assertEquals("", sanitizedHtml, "Empty input should result in empty string");
@@ -348,7 +323,7 @@ class CustomHtmlSanitizerTest {
@Test
void testSanitizeHandlesNull() {
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(null);
String sanitizedHtml = CustomHtmlSanitizer.sanitize(null);
// Assert
assertEquals("", sanitizedHtml, "Null input should result in empty string");
File diff suppressed because it is too large Load Diff
@@ -19,7 +19,6 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.configuration.RuntimePathConfig;
@ExtendWith(MockitoExtension.class)
@@ -3,40 +3,19 @@ package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.anyString;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.service.SsrfProtectionService;
public class FileToPdfTest {
private CustomHtmlSanitizer customHtmlSanitizer;
@BeforeEach
void setUp() {
SsrfProtectionService mockSsrfProtectionService = mock(SsrfProtectionService.class);
stirling.software.common.model.ApplicationProperties mockApplicationProperties =
mock(stirling.software.common.model.ApplicationProperties.class);
stirling.software.common.model.ApplicationProperties.System mockSystem =
mock(stirling.software.common.model.ApplicationProperties.System.class);
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(true);
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
when(mockSystem.getDisableSanitize()).thenReturn(false);
customHtmlSanitizer =
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
}
/**
* Test the HTML to PDF conversion. This test expects an IOException when an empty HTML input is
* provided.
@@ -46,13 +25,14 @@ public class FileToPdfTest {
HTMLToPdfRequest request = new HTMLToPdfRequest();
byte[] fileBytes = new byte[0]; // Sample file bytes (empty input)
String fileName = "test.html"; // Sample file name indicating an HTML file
boolean disableSanitize = false; // Flag to control sanitization
TempFileManager tempFileManager = mock(TempFileManager.class); // Mock TempFileManager
// Mock the temp file creation to return real temp files
try {
when(tempFileManager.createTempFile(anyString()))
.thenReturn(Files.createTempFile("test", ".pdf").toFile())
.thenReturn(Files.createTempFile("test", ".html").toFile());
.thenReturn(File.createTempFile("test", ".pdf"))
.thenReturn(File.createTempFile("test", ".html"));
} catch (IOException e) {
throw new RuntimeException(e);
}
@@ -63,12 +43,7 @@ public class FileToPdfTest {
Exception.class,
() ->
FileToPdf.convertHtmlToPdf(
"/path/",
request,
fileBytes,
fileName,
tempFileManager,
customHtmlSanitizer));
"/path/", request, fileBytes, fileName, disableSanitize, tempFileManager));
assertNotNull(thrown);
}
@@ -1,23 +1,21 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.enumeration.UsernameAttribute;
import stirling.software.common.model.oauth2.GitHubProvider;
import stirling.software.common.model.oauth2.GoogleProvider;
import stirling.software.common.model.oauth2.Provider;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ProviderUtilsTest {
@@ -42,7 +40,7 @@ class ProviderUtilsTest {
public static Stream<Arguments> providerParams() {
Provider generic = null;
var google =
new GoogleProvider(null, "clientSecret", List.of("scope"), UsernameAttribute.EMAIL);
new GoogleProvider(null, "clientSecret", List.of("scope"), UsernameAttribute.EMAIL);
var github = new GitHubProvider("clientId", "", List.of("scope"), UsernameAttribute.LOGIN);
return Stream.of(Arguments.of(generic), Arguments.of(google), Arguments.of(github));
@@ -42,6 +42,7 @@ class SpringContextHolderTest {
verify(mockApplicationContext).getBean(TestBean.class);
}
@Test
void testGetBean_ApplicationContextNotSet() {
// Don't set application context
@@ -57,8 +58,7 @@ class SpringContextHolderTest {
void testGetBean_BeanNotFound() {
// Arrange
contextHolder.setApplicationContext(mockApplicationContext);
when(mockApplicationContext.getBean(TestBean.class))
.thenThrow(new org.springframework.beans.BeansException("Bean not found") {});
when(mockApplicationContext.getBean(TestBean.class)).thenThrow(new org.springframework.beans.BeansException("Bean not found") {});
// Act
TestBean result = SpringContextHolder.getBean(TestBean.class);
@@ -68,5 +68,6 @@ class SpringContextHolderTest {
}
// Simple test class
private static class TestBean {}
private static class TestBean {
}
}
@@ -1,12 +1,10 @@
package stirling.software.common.util.misc;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.api.misc.HighContrastColorCombination;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class HighContrastColorReplaceDeciderTest {
@@ -26,7 +26,6 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.io.InputStreamResource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
class InvertFullColorStrategyTest {
@@ -9,7 +9,6 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.io.InputStreamResource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
class ReplaceAndInvertColorStrategyTest {
@@ -1,17 +1,14 @@
package stirling.software.common.util.propertyeditor;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.api.security.RedactionArea;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.api.security.RedactionArea;
class StringToArrayListPropertyEditorTest {
private StringToArrayListPropertyEditor editor;
+2 -3
View File
@@ -16,7 +16,8 @@ local.properties
version.properties
#### Stirling-PDF Files ###
pipeline/*
pipeline/watchedFolders/
pipeline/finishedFolders/
customFiles/
configs/
watchedFolders/
@@ -193,5 +194,3 @@ id_ed25519.pub
# node_modules
node_modules/
scripts/**/*
+3 -15
View File
@@ -14,7 +14,7 @@ configurations {
spotless {
java {
target 'src/**/java/**/*.java'
target sourceSets.main.allJava
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
@@ -23,18 +23,6 @@ spotless {
leadingTabsToSpaces()
endWithNewline()
}
yaml {
target '**/*.yml', '**/*.yaml'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
format 'gradle', {
target '**/gradle/*.gradle', '**/*.gradle'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
}
dependencies {
@@ -55,7 +43,7 @@ dependencies {
implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-jetty'
implementation 'com.posthog.java:posthog:1.2.0'
implementation 'commons-io:commons-io:2.20.0'
implementation 'commons-io:commons-io:2.19.0'
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
implementation 'io.micrometer:micrometer-core:1.15.2'
@@ -74,7 +62,7 @@ dependencies {
exclude group: 'com.google.code.gson', module: 'gson'
}
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
implementation 'com.opencsv:opencsv:5.11.2' // https://mvnrepository.com/artifact/com.opencsv/opencsv
// Batik
implementation 'org.apache.xmlgraphics:batik-all:1.19'
@@ -36,15 +36,9 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String requestURI = request.getRequestURI();
// Skip URL cleaning for API endpoints - they need their own parameter handling
if (requestURI.contains("/api/")) {
return true;
}
String queryString = request.getQueryString();
if (queryString != null && !queryString.isEmpty()) {
String requestURI = request.getRequestURI();
Map<String, String> allowedParameters = new HashMap<>();
// Keep only the allowed parameters
@@ -238,14 +238,14 @@ public class EndpointConfiguration {
addEndpointToGroup("PageOps", "rotate-pdf");
addEndpointToGroup("PageOps", "multi-page-layout");
addEndpointToGroup("PageOps", "scale-pages");
addEndpointToGroup("PageOps", "adjust-contrast");
addEndpointToGroup("PageOps", "crop");
addEndpointToGroup("PageOps", "auto-split-pdf");
addEndpointToGroup("PageOps", "extract-page");
addEndpointToGroup("PageOps", "pdf-to-single-page");
addEndpointToGroup("PageOps", "auto-split-pdf");
addEndpointToGroup("PageOps", "split-by-size-or-count");
addEndpointToGroup("PageOps", "overlay-pdf");
addEndpointToGroup("PageOps", "split-pdf-by-sections");
addEndpointToGroup("PageOps", "split-pdf-by-chapters");
// Adding endpoints to "Convert" group
addEndpointToGroup("Convert", "pdf-to-img");
@@ -274,43 +274,27 @@ public class EndpointConfiguration {
addEndpointToGroup("Security", "sanitize-pdf");
addEndpointToGroup("Security", "auto-redact");
addEndpointToGroup("Security", "redact");
addEndpointToGroup("Security", "validate-signature");
addEndpointToGroup("Security", "stamp");
addEndpointToGroup("Security", "sign");
// Adding endpoints to "Other" group
addEndpointToGroup("Other", "ocr-pdf");
addEndpointToGroup("Other", "add-image");
addEndpointToGroup("Other", "compress-pdf");
addEndpointToGroup("Other", "extract-images");
addEndpointToGroup("Other", "change-metadata");
addEndpointToGroup("Other", "extract-image-scans");
addEndpointToGroup("Other", "sign");
addEndpointToGroup("Other", "flatten");
addEndpointToGroup("Other", "repair");
addEndpointToGroup("Other", "unlock-pdf-forms");
addEndpointToGroup("Other", REMOVE_BLANKS);
addEndpointToGroup("Other", "remove-annotations");
addEndpointToGroup("Other", "compare");
addEndpointToGroup("Other", "add-page-numbers");
addEndpointToGroup("Other", "auto-rename");
addEndpointToGroup("Other", "get-info-on-pdf");
addEndpointToGroup("Other", "show-javascript");
addEndpointToGroup("Other", "remove-image-pdf");
addEndpointToGroup("Other", "add-attachments");
addEndpointToGroup("Other", "view-pdf");
addEndpointToGroup("Other", "replace-and-invert-color-pdf");
addEndpointToGroup("Other", "multi-tool");
// Adding endpoints to "Advance" group
addEndpointToGroup("Advance", "adjust-contrast");
addEndpointToGroup("Advance", "compress-pdf");
addEndpointToGroup("Advance", "extract-image-scans");
addEndpointToGroup("Advance", "repair");
addEndpointToGroup("Advance", "auto-rename");
addEndpointToGroup("Advance", "pipeline");
addEndpointToGroup("Advance", "scanner-effect");
addEndpointToGroup("Advance", "auto-split-pdf");
addEndpointToGroup("Advance", "show-javascript");
addEndpointToGroup("Advance", "split-by-size-or-count");
addEndpointToGroup("Advance", "overlay-pdf");
addEndpointToGroup("Advance", "split-pdf-by-sections");
addEndpointToGroup("Advance", "edit-table-of-contents");
addEndpointToGroup("Advance", "split-pdf-by-chapters");
// CLI
addEndpointToGroup("CLI", "compress-pdf");
@@ -421,6 +405,7 @@ public class EndpointConfiguration {
// file-to-pdf has multiple implementations
addEndpointAlternative("file-to-pdf", "LibreOffice");
addEndpointAlternative("file-to-pdf", "Python");
addEndpointAlternative("file-to-pdf", "Unoconvert");
// pdf-to-html and pdf-to-markdown can use either LibreOffice or Pdftohtml
@@ -35,7 +35,6 @@ public class InitialSetup {
initEnableCSRFSecurity();
initLegalUrls();
initSetAppVersion();
GeneralUtils.extractPipeline();
}
public void initUUIDKey() throws IOException {
@@ -23,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.EmlToPdf;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -38,7 +37,6 @@ public class ConvertEmlToPDF {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
private final TempFileManager tempFileManager;
private final CustomHtmlSanitizer customHtmlSanitizer;
@PostMapping(consumes = "multipart/form-data", value = "/eml/pdf")
@Operation(
@@ -105,9 +103,9 @@ public class ConvertEmlToPDF {
request,
fileBytes,
originalFilename,
false,
pdfDocumentFactory,
tempFileManager,
customHtmlSanitizer);
tempFileManager);
if (pdfBytes == null || pdfBytes.length == 0) {
log.error("PDF conversion failed - empty output for {}", originalFilename);
@@ -14,9 +14,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.TempFileManager;
@@ -30,12 +30,12 @@ public class ConvertHtmlToPDF {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ApplicationProperties applicationProperties;
private final RuntimePathConfig runtimePathConfig;
private final TempFileManager tempFileManager;
private final CustomHtmlSanitizer customHtmlSanitizer;
@PostMapping(consumes = "multipart/form-data", value = "/html/pdf")
@Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
@@ -57,14 +57,17 @@ public class ConvertHtmlToPDF {
"error.fileFormatRequired", "File must be in {0} format", ".html or .zip");
}
boolean disableSanitize =
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
byte[] pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
request,
fileInput.getBytes(),
originalFilename,
tempFileManager,
customHtmlSanitizer);
disableSanitize,
tempFileManager);
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
@@ -117,14 +117,10 @@ public class ConvertImgPDFController {
}
String pythonVersion = CheckProgramInstall.getAvailablePythonCommand();
Path pngToWebpScript = GeneralUtils.extractScript("png_to_webp.py");
List<String> command = new ArrayList<>();
command.add(pythonVersion);
command.add(
pngToWebpScript
.toAbsolutePath()
.toString()); // Python script to handle the conversion
command.add("./scripts/png_to_webp.py"); // Python script to handle the conversion
// Create a temporary directory for the output WebP files
tempOutputDir = Files.createTempDirectory("webp_output");
@@ -236,8 +232,7 @@ public class ConvertImgPDFController {
PdfUtils.imageToPdf(file, fitOption, autoRotate, colorType, pdfDocumentFactory);
return WebResponseUtils.bytesToWebResponse(
bytes,
new File(file[0].getOriginalFilename()).getName().replaceFirst("[.][^.]+$", "")
+ "_converted.pdf");
new File(file[0].getOriginalFilename()).getName().replaceFirst("[.][^.]+$", "") + "_converted.pdf");
}
private String getMediaType(String imageFormat) {
@@ -24,9 +24,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.TempFileManager;
@@ -39,12 +39,12 @@ import stirling.software.common.util.WebResponseUtils;
public class ConvertMarkdownToPdf {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ApplicationProperties applicationProperties;
private final RuntimePathConfig runtimePathConfig;
private final TempFileManager tempFileManager;
private final CustomHtmlSanitizer customHtmlSanitizer;
@PostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
@Operation(
summary = "Convert a Markdown file to PDF",
@@ -79,14 +79,17 @@ public class ConvertMarkdownToPdf {
String htmlContent = renderer.render(document);
boolean disableSanitize =
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
byte[] pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
htmlContent.getBytes(),
"converted.html",
tempFileManager,
customHtmlSanitizer);
disableSanitize,
tempFileManager);
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
String outputFilename =
originalFilename.replaceFirst("[.][^.]+$", "")
@@ -2,7 +2,6 @@ package stirling.software.SPDF.controller.api.converters;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -27,7 +26,6 @@ import lombok.RequiredArgsConstructor;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.WebResponseUtils;
@@ -40,7 +38,6 @@ public class ConvertOfficeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
private final CustomHtmlSanitizer customHtmlSanitizer;
public File convertToPdf(MultipartFile inputFile) throws IOException, InterruptedException {
// Check for valid file extension
@@ -53,17 +50,7 @@ public class ConvertOfficeController {
// Save the uploaded file to a temporary location
Path tempInputFile =
Files.createTempFile("input_", "." + FilenameUtils.getExtension(originalFilename));
// Check if the file is HTML and apply sanitization if needed
String fileExtension = FilenameUtils.getExtension(originalFilename).toLowerCase();
if ("html".equals(fileExtension) || "htm".equals(fileExtension)) {
// Read and sanitize HTML content
String htmlContent = new String(inputFile.getBytes(), StandardCharsets.UTF_8);
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlContent);
Files.write(tempInputFile, sanitizedHtml.getBytes(StandardCharsets.UTF_8));
} else {
inputFile.transferTo(tempInputFile);
}
inputFile.transferTo(tempInputFile);
// Prepare the output file path
Path tempOutputFile = Files.createTempFile("output_", ".pdf");
@@ -34,7 +34,6 @@ import stirling.software.SPDF.model.api.misc.ExtractImageScansRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.WebResponseUtils;
@@ -79,7 +78,6 @@ public class ExtractImageScansController {
}
String pythonVersion = CheckProgramInstall.getAvailablePythonCommand();
Path splitPhotosScript = GeneralUtils.extractScript("split_photos.py");
try {
// Check if input file is a PDF
if ("pdf".equalsIgnoreCase(extension)) {
@@ -122,7 +120,7 @@ public class ExtractImageScansController {
new ArrayList<>(
Arrays.asList(
pythonVersion,
splitPhotosScript.toAbsolutePath().toString(),
"./scripts/split_photos.py",
images.get(i),
tempDir.toString(),
"--angle_threshold",
@@ -47,8 +47,7 @@ public class PrintFileController {
throws IOException {
MultipartFile file = request.getFileInput();
String originalFilename = file.getOriginalFilename();
if (originalFilename != null
&& (originalFilename.contains("..") || Paths.get(originalFilename).isAbsolute())) {
if (originalFilename != null && (originalFilename.contains("..") || Paths.get(originalFilename).isAbsolute())) {
throw new IOException("Invalid file path detected: " + originalFilename);
}
String printerName = request.getPrinterName();
@@ -42,6 +42,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import java.lang.IllegalArgumentException;
@RestController
@RequestMapping("/api/v1/misc")
@@ -66,21 +67,13 @@ public class StampController {
if (pdfFileName.contains("..") || pdfFileName.startsWith("/")) {
throw new IllegalArgumentException("Invalid PDF file path");
}
String stampType = request.getStampType();
String stampText = request.getStampText();
MultipartFile stampImage = request.getStampImage();
if ("image".equalsIgnoreCase(stampType)) {
if (stampImage == null) {
throw new IllegalArgumentException(
"Stamp image file must be provided when stamp type is 'image'");
}
String stampImageName = stampImage.getOriginalFilename();
if (stampImageName == null
|| stampImageName.contains("..")
|| stampImageName.startsWith("/")) {
throw new IllegalArgumentException("Invalid stamp image file path");
}
String stampImageName = stampImage.getOriginalFilename();
if (stampImageName.contains("..") || stampImageName.startsWith("/")) {
throw new IllegalArgumentException("Invalid stamp image file path");
}
String alphabet = request.getAlphabet();
float fontSize = request.getFontSize();
@@ -108,13 +108,9 @@ public class PipelineProcessor {
if (inputFileTypes == null) {
inputFileTypes = new ArrayList<String>(Arrays.asList("ALL"));
}
if (!apiDocService.isValidOperation(operation, parameters)) {
log.error("Invalid operation or parameters: o:{} p:{}", operation, parameters);
throw new IllegalArgumentException(
"Invalid operation: " + operation + " with parameters: " + parameters);
if (!operation.matches("^[a-zA-Z0-9_-]+$")) {
throw new IllegalArgumentException("Invalid operation value received.");
}
String url = getBaseUrl() + operation;
List<Resource> newOutputFiles = new ArrayList<>();
if (!isMultiInputOperation) {
@@ -140,7 +136,7 @@ public class PipelineProcessor {
// skip
// this
// file
if (operation.startsWith("/api/v1/filter/filter-")
if (operation.startsWith("filter-")
&& (response.getBody() == null
|| response.getBody().length == 0)) {
filtersApplied = true;
@@ -335,8 +331,7 @@ public class PipelineProcessor {
for (File file : files) {
Path normalizedPath = Paths.get(file.getName()).normalize();
if (normalizedPath.startsWith("..")) {
throw new SecurityException(
"Potential path traversal attempt in file name: " + file.getName());
throw new SecurityException("Potential path traversal attempt in file name: " + file.getName());
}
Path path = Paths.get(file.getAbsolutePath());
// debug statement
@@ -83,9 +83,7 @@ public class WatermarkController {
MultipartFile watermarkImage = request.getWatermarkImage();
if (watermarkImage != null) {
String watermarkImageFileName = watermarkImage.getOriginalFilename();
if (watermarkImageFileName != null
&& (watermarkImageFileName.contains("..")
|| watermarkImageFileName.startsWith("/"))) {
if (watermarkImageFileName != null && (watermarkImageFileName.contains("..") || watermarkImageFileName.startsWith("/"))) {
throw new SecurityException("Invalid file path in watermarkImage");
}
}
@@ -1282,7 +1282,7 @@ merge.header=Több PDF egyesítése (2+)
merge.sortByName=Rendezés név szerint
merge.sortByDate=Rendezés dátum szerint
merge.removeCertSign=Digitális aláírás eltávolítása az egyesített fájlban?
merge.generateToc=Tartalomjegyzék létrehozása az egyesített fájlban?
merge.generateToc=Generate table of contents in the merged file?
merge.submit=Egyesítés
@@ -221,7 +221,7 @@ error.fileNotFound=Файл с идентификатором: {0} не найд
# Database and configuration messages
error.noBackupScripts=Сценарий резервного копирования не найден
error.unsupportedProvider={0} в настоящее время не поддерживается.
error.pathTraversalDetected=Обнаружено пересечение пути по соображениям безопасности.
error.pathTraversalDetected=Path traversal detected for security reasons.
# Validation messages
error.invalidArgument=Недопустимый аргумент: {0}
@@ -439,8 +439,8 @@ adminUserSettings.activeUsers=Активные пользователи:
adminUserSettings.disabledUsers=Отключенные пользователи:
adminUserSettings.totalUsers=Всего пользователей:
adminUserSettings.lastRequest=Последний запрос
adminUserSettings.usage=Просмотр использования
adminUserSettings.teams=Просмотр/редактирование групп
adminUserSettings.usage=View Usage
adminUserSettings.teams=View/Edit Teams
adminUserSettings.team=Группа
adminUserSettings.manageTeams=Управление группами
adminUserSettings.createTeam=Создать группу
@@ -454,34 +454,34 @@ adminUserSettings.teamHidden=Скрытая
adminUserSettings.totalMembers=Общее количество участников
adminUserSettings.confirmDeleteTeam=Вы уверены, что хотите удалить эту группу?
teamCreated=Группа успешно создана
teamExists=Группа с таким названием уже существует
teamNameExists=Другая группа с таким названием уже существует
teamNotFound=Группа не найдена
teamDeleted=Группа удалена
teamHasUsers=Не удается удалить группу с назначенными пользователями
teamRenamed=Команда успешно переименована
teamCreated=Team created successfully
teamExists=A team with that name already exists
teamNameExists=Another team with that name already exists
teamNotFound=Team not found
teamDeleted=Team deleted
teamHasUsers=Cannot delete a team with users assigned
teamRenamed=Team renamed successfully
# Team user management
team.addUser=Добавить пользователя в группу
team.selectUser=Выбрать пользователя
team.warning.moveUser=Внимание: Это приведет к перемещению пользователя из группы "{0}" в группу "{1}". Вы уверены?
team.confirm.moveUser=Вы уверены, что хотите перевести этого пользователя из группы "{0}" в группу "{1}"?
team.userAdded=Пользователь успешно добавлен в группу
team.back=Назад в группы
team.internal=Внутренняя группа
team.internalTeamNotAccessible=Внутренняя группа - это системная группа, и доступ к ней невозможен
team.cannotMoveInternalUsers=Пользователи из внутренней группы не могут быть переведены в другие группы
team.hidden=Скрытая
team.name=Имя группы
team.totalMembers=Общее количество участников
team.members=Участники
team.username=Имя пользователя
team.role=Роль
team.status=Статус
team.enabled=Включен
team.disabled=Отключен
team.noMembers=В этой команде пока нет участников.
team.addUser=Add User to Team
team.selectUser=Select User
team.warning.moveUser=Warning: This will move the user from "{0}" team to "{1}" team. Are you sure?
team.confirm.moveUser=Are you sure you want to move this user from "{0}" team to "{1}" team?
team.userAdded=User successfully added to team
team.back=Back to Teams
team.internal=Internal Team
team.internalTeamNotAccessible=The Internal team is a system team and cannot be accessed
team.cannotMoveInternalUsers=Users in the Internal team cannot be moved to other teams
team.hidden=Hidden
team.name=Team Name
team.totalMembers=Total Members
team.members=Members
team.username=Username
team.role=Role
team.status=Status
team.enabled=Enabled
team.disabled=Disabled
team.noMembers=This team has no members yet.
@@ -565,16 +565,16 @@ split.tags=операции со страницами,разделение,мн
home.rotate.title=Повернуть
home.rotate.desc=Легко поворачивайте ваши PDF-файлы.
rotate.tags=повернуть,наклонить
rotate.tags=серверная часть
home.imageToPdf.title=Изображение в PDF
home.imageToPdf.desc=Преобразование изображения (PNG, JPEG, GIF) в PDF.
imageToPdf.tags=png,jpeg,gif,конвертация,изображение,картинка,фото
imageToPdf.tags=конвертация,изображение,jpg,картинка,фото
home.pdfToImage.title=PDF в изображение
home.pdfToImage.desc=Преобразование PDF в изображение (PNG, JPEG, GIF).
pdfToImage.tags=png,jpeg,gif,конвертация,изображение,картинка,фото
pdfToImage.tags=конвертация,изображение,jpg,картинка,фото
home.pdfOrganiser.title=Организация
home.pdfOrganiser.desc=Удаление/переупорядочивание страниц в любом порядке
@@ -587,7 +587,7 @@ addImage.tags=изображение,jpg,картинка,фото
home.attachments.title=Добавлять вложения
home.attachments.desc=Добавление или удаление встроенных файлов (вложений) в PDF-файл или из него
attachments.tags=вставлять,прикреплять,файл,вложение,вложения
attachments.tags=embed,attach,file,attachment,attachments
home.watermark.title=Добавить водяной знак
home.watermark.desc=Добавьте собственный водяной знак в ваш PDF-документ.
@@ -615,8 +615,8 @@ home.compressPdfs.desc=Сжимайте PDF-файлы для уменьшени
compressPdfs.tags=сжатие,маленький,крошечный
home.unlockPDFForms.title=Разблокировать PDF-формы
home.unlockPDFForms.desc=Удалите свойство 'только для чтения' для полей формы в PDF-документа.
unlockPDFForms.tags=удалить,форма,поле,только для чтения
home.unlockPDFForms.desc=Удалите свойство "только для чтения" для полей формы в PDF-документа.
unlockPDFForms.tags=remove,delete,form,field,readonly
home.changeMetadata.title=Изменить метаданные
home.changeMetadata.desc=Изменить/удалить/добавить метаданные из PDF-документа
@@ -644,15 +644,15 @@ PDFToWord.tags=doc,docx,odt,word,преобразование,формат,ко
home.PDFToPresentation.title=PDF в презентацию
home.PDFToPresentation.desc=Преобразование PDF в форматы презентаций (PPT, PPTX и ODP)
PDFToPresentation.tags=слайды,презентация,офис,microsoft
PDFToPresentation.tags=слайды,показ,офис,microsoft
home.PDFToText.title=PDF в RTF (текст)
home.PDFToText.desc=Преобразование PDF в текстовый или RTF формат
PDFToText.tags=rtf,richformat,richtextformat,rich text format
PDFToText.tags=richformat,richtextformat,rich text format
home.PDFToHTML.title=PDF в HTML
home.PDFToHTML.desc=Преобразование PDF в формат HTML
PDFToHTML.tags=html,веб-контент,браузерный формат
PDFToHTML.tags=веб-контент,браузерный формат
home.PDFToXML.title=PDF в XML
@@ -733,16 +733,16 @@ sanitizePdf.tags=очистка,безопасность,защита,удале
home.URLToPDF.title=URL/веб-сайт в PDF
home.URLToPDF.desc=Преобразует любой http(s)URL в PDF
URLToPDF.tags=url,веб-захват,сохранение страницы,веб в док,архивация
URLToPDF.tags=веб-захват,сохранение страницы,веб в док,архивация
home.HTMLToPDF.title=HTML в PDF
home.HTMLToPDF.desc=Преобразует любой HTML-файл или zip в PDF
HTMLToPDF.tags=html,разметка,веб-контент,преобразование,конвертация
HTMLToPDF.tags=разметка,веб-контент,преобразование,конвертация
#eml-to-pdf
home.EMLToPDF.title=Email в PDF
home.EMLToPDF.desc=Преобразует файлы электронной почты (EML) в формат PDF, включая заголовки, основную часть и встроенные изображения
EMLToPDF.tags=eml,email,сообщение,преобразование,конвертация,почта,письмо
EMLToPDF.tags=email,conversion,eml,message,transformation,convert,mail
EMLToPDF.title=Email в PDF
EMLToPDF.header=Email в PDF
@@ -752,17 +752,17 @@ EMLToPDF.downloadHtmlHelp=Это позволит вам просмотреть
EMLToPDF.includeAttachments=Включать вложения в формате PDF
EMLToPDF.maxAttachmentSize=Максимальный размер вложения (MB)
EMLToPDF.help=Преобразует файлы электронной почты (EML) в формат PDF, включая заголовки, основную часть и встроенные изображения
EMLToPDF.troubleshootingTip1=Электронная почта в формате HTML является более надежным процессом, поэтому при пакетной обработке рекомендуется сохранять оба формата
EMLToPDF.troubleshootingTip1=Электронная почта в формате HTML является более надежным процессом, поэтому при пакетной обработке рекомендуется сохранять оба
EMLToPDF.troubleshootingTip2=При небольшом количестве электронных писем, если формат PDF искажен, вы можете загрузить HTML и переопределить часть проблемного HTML/CSS-кода.
EMLToPDF.troubleshootingTip3=Однако, встраивания не работают с HTMLs
EMLToPDF.troubleshootingTip3=Embeddings, however, do not work with HTMLs
home.MarkdownToPDF.title=Markdown в PDF
home.MarkdownToPDF.desc=Преобразует любой файл Markdown в PDF
MarkdownToPDF.tags=разметка,веб-контент,преобразование,конвертация,md
MarkdownToPDF.tags=разметка,веб-контент,преобразование,конвертация
home.PDFToMarkdown.title=PDF to Markdown
home.PDFToMarkdown.desc=Преобразует любой PDF-файл в формат Markdown
PDFToMarkdown.tags=разметка,веб-контент,преобразование,конвертацтя,md
PDFToMarkdown.tags=markup,web-content,transformation,convert,md
home.getPdfInfo.title=Получить ВСЮ информацию о PDF
home.getPdfInfo.desc=Собирает всю возможную информацию о PDF
@@ -781,11 +781,11 @@ PdfToSinglePage.tags=одна страница
home.showJS.title=Показать Javascript
home.showJS.desc=Ищет и отображает любой JS, внедрённый в PDF
showJS.tags=JS,Javascript
showJS.tags=JS
home.autoRedact.title=Автоматическое редактирование
home.autoRedact.desc=Автоматически закрашивает (чернит) текст в PDF на основе входного текста
autoRedact.tags=Редактирование,Скрытие,зачернение,чёрный,маркер,скрытый,автоматический
autoRedact.tags=Редактирование,Скрытие,зачернение,чёрный,маркер,скрытый
home.redact.title=Ручное редактирование
home.redact.desc=Редактирует PDF на основе выбранного текста, нарисованных форм и/или выбранных страниц
@@ -798,7 +798,7 @@ tableExtraxt.tags=CSV,Извлечение таблиц,извлечение,к
home.autoSizeSplitPDF.title=Авторазделение по размеру/количеству
home.autoSizeSplitPDF.desc=Разделяет один PDF на несколько документов на основе размера, количества страниц или количества документов
autoSizeSplitPDF.tags=разделение,документ,организация
autoSizeSplitPDF.tags=pdf,разделение,документ,организация
home.overlay-pdfs.title=Наложение PDF
@@ -807,25 +807,25 @@ overlay-pdfs.tags=Наложение
home.split-by-sections.title=Разделить PDF по секциям
home.split-by-sections.desc=Разделяет каждую страницу PDF на меньшие горизонтальные и вертикальные секции
split-by-sections.tags=Разделение по секциям,Разделить
split-by-sections.tags=Разделение по секциям,Разделить,Настроить
home.AddStampRequest.title=Добавить штамп в PDF
home.AddStampRequest.desc=Добавляет текстовые или графические штампы в указанных местах
AddStampRequest.tags=Штамп,Добавить изображение,центрировать изображение,Водяной знак,Встраивание
AddStampRequest.tags=Штамп,Добавить изображение,центрировать изображение,Водяной знак,PDF,Встраивание,Настройка
home.removeImagePdf.title=Удалить изображение
home.removeImagePdf.desc=Удаляет изображения из PDF для уменьшения размера файла
removeImagePdf.tags=Удаление изображения,операции со страницами
removeImagePdf.tags=Удаление изображения,операции со страницами,Серверная часть
home.splitPdfByChapters.title=Разделить PDF по главам
home.splitPdfByChapters.desc=Разделяет PDF на несколько файлов на основе структуры его глав
splitPdfByChapters.tags=разделение,главы,закладки,оглавление
splitPdfByChapters.tags=разделение,главы,закладки,организация
home.validateSignature.title=Проверка подписи PDF
home.validateSignature.desc=Проверка цифровых подписей и сертификатов в PDF-документах
validateSignature.tags=подпись,проверка,валидация,сертификат,цифровая подпись,проверка подписи,проверка сертификата
validateSignature.tags=подпись,проверка,валидация,pdf,сертификат,цифровая подпись,Проверка подписи,Проверка сертификата
#replace-invert-color
replace-color.title=Замена-Инверсия цвета
@@ -941,7 +941,7 @@ getPdfInfo.title=Получить информацию о PDF
getPdfInfo.header=Получить информацию о PDF
getPdfInfo.submit=Получить информацию
getPdfInfo.downloadJson=Скачать JSON
getPdfInfo.summary=Краткое содержание PDF-файла
getPdfInfo.summary=PDF Summary
getPdfInfo.summary.encrypted=Этот PDF-файл зашифрован, поэтому с некоторыми приложениями могут возникнуть проблемы
getPdfInfo.summary.permissions=Этот PDF-файл имеет {0} ограниченные права доступа, которые могут ограничить то, что вы можете с ним делать
getPdfInfo.summary.compliance=Этот PDF-файл соответствует стандарту {0}
@@ -951,18 +951,18 @@ getPdfInfo.summary.encrypted.alert=Зашифрованный PDF-файл - э
getPdfInfo.summary.not.encrypted.alert=Незашифрованный PDF-файл - без защиты паролем
getPdfInfo.summary.permissions.alert=Права доступа ограничены - {0} действия запрещены
getPdfInfo.summary.all.permissions.alert=Полные права доступа
getPdfInfo.summary.compliance.alert={0} Совместимый
getPdfInfo.summary.no.compliance.alert=Не содержит стандартов соответствия
getPdfInfo.summary.security.section=Уровень безопасности
getPdfInfo.section.BasicInfo=Основная информация о документе PDF включает: размер файла, количество страниц и язык
getPdfInfo.section.Metadata=Метаданные документа содержат: название, автора, дату создания и другие свойства документа
getPdfInfo.section.DocumentInfo=Технические подробности о структуре и версии PDF-документа
getPdfInfo.section.Compliancy=Информация о соответствии стандартам PDF (PDF/A, PDF/X, и т.д.)
getPdfInfo.section.Encryption=Сведения о безопасности и шифровании документа
getPdfInfo.section.Permissions=Настройки разрешений документа, контролирующие, какие действия можно выполнять
getPdfInfo.section.Other=Дополнительные компоненты документа, такие как закладки, слои и встроенные файлы
getPdfInfo.section.FormFields=Поля интерактивной формы, присутствующие в документе
getPdfInfo.section.PerPageInfo=Подробная информация о каждой странице документа
getPdfInfo.summary.compliance.alert={0} Compliant
getPdfInfo.summary.no.compliance.alert=No Compliance Standards
getPdfInfo.summary.security.section=Security Status
getPdfInfo.section.BasicInfo=Basic Information about the PDF document including file size, page count, and language
getPdfInfo.section.Metadata=Document metadata including title, author, creation date and other document properties
getPdfInfo.section.DocumentInfo=Technical details about the PDF document structure and version
getPdfInfo.section.Compliancy=PDF standards compliance information (PDF/A, PDF/X, etc.)
getPdfInfo.section.Encryption=Security and encryption details of the document
getPdfInfo.section.Permissions=Document permission settings that control what actions can be performed
getPdfInfo.section.Other=Additional document components like bookmarks, layers, and embedded files
getPdfInfo.section.FormFields=Interactive form fields present in the document
getPdfInfo.section.PerPageInfo=Detailed information about each page in the document
#markdown-to-pdf
@@ -970,7 +970,7 @@ MarkdownToPDF.title=Markdown в PDF
MarkdownToPDF.header=Markdown в PDF
MarkdownToPDF.submit=Преобразовать
MarkdownToPDF.help=В разработке
MarkdownToPDF.credit=Этот сервис использует WeasyPrint для преобразования файлов
MarkdownToPDF.credit=Использует WeasyPrint
#pdf-to-markdown
@@ -983,7 +983,7 @@ PDFToMarkdown.submit=Преобразовать
URLToPDF.title=URL в PDF
URLToPDF.header=URL в PDF
URLToPDF.submit=Преобразовать
URLToPDF.credit=Этот сервис использует WeasyPrint для преобразования файлов
URLToPDF.credit=Использует WeasyPrint
#html-to-pdf
@@ -991,7 +991,7 @@ HTMLToPDF.title=HTML в PDF
HTMLToPDF.header=HTML в PDF
HTMLToPDF.help=Принимает HTML-файлы и ZIP-архивы, содержащие html/css/изображения и т.д.
HTMLToPDF.submit=Преобразовать
HTMLToPDF.credit=Этот сервис использует WeasyPrint для преобразования файлов
HTMLToPDF.credit=Использует WeasyPrint
HTMLToPDF.zoom=Уровень масштабирования для отображения веб-сайта.
HTMLToPDF.pageWidth=Ширина страницы в сантиметрах. (Пусто для значения по умолчанию)
HTMLToPDF.pageHeight=Высота страницы в сантиметрах. (Пусто для значения по умолчанию)
@@ -1047,7 +1047,7 @@ addPageNumbers.selectText.4=Начальный номер
addPageNumbers.selectText.5=Страницы для нумерации
addPageNumbers.selectText.6=Пользовательский текст
addPageNumbers.customTextDesc=Пользовательский текст
addPageNumbers.numberPagesDesc=Какие страницы нумеровать, по умолчанию 'все', также принимаются значения 1-5 или 2,5,9 и т.д.
addPageNumbers.numberPagesDesc=Какие страницы нумеровать, по умолчанию 'все', также принимает 1-5 или 2,5,9 и т.д.
addPageNumbers.customNumberDesc=По умолчанию {n}, также принимает 'Страница {n} из {total}', 'Текст-{n}', '{filename}-{n}'
addPageNumbers.submit=Добавить номера страниц
@@ -1083,7 +1083,7 @@ autoSplitPDF.selectText.3=Загрузите один большой отска
autoSplitPDF.selectText.4=Разделительные страницы автоматически обнаруживаются и удаляются, гарантируя аккуратный конечный документ.
autoSplitPDF.formPrompt=Отправить PDF, содержащий разделители страниц Stirling-PDF:
autoSplitPDF.duplexMode=Двусторонний режим (сканирование с двух сторон)
autoSplitPDF.dividerDownload2=Скачать 'Auto Splitter Divider (with instructions).pdf'
autoSplitPDF.dividerDownload2=Скачать 'Автоматический разделитель (с инструкциями).pdf'
autoSplitPDF.submit=Отправить
@@ -1253,7 +1253,7 @@ fileToPDF.submit=Преобразовать в PDF
compress.title=Сжать
compress.header=Сжать PDF
compress.credit=Этот сервис использует qpdf для сжатия/оптимизации PDF.
compress.grayscale.label=Примените оттенки серого для сжатия
compress.grayscale.label=Применить шкалу серого для сжатия
compress.selectText.1=Параметры сжатия
compress.selectText.1.1=1-3 сжатие PDF,</br> 4-6 лёгкое сжатие изображений,</br> 7-9 интенсивное сжатие изображений (значительно снижает качество изображений)
compress.selectText.2=Уровень оптимизации:
@@ -1265,7 +1265,7 @@ compress.submit=Сжать
#Add image
addImage.title=Добавить изображение
addImage.header=Добавить изображение в PDF
addImage.everyPage=На каждую страницу?
addImage.everyPage=Каждая страница?
addImage.upload=Добавить изображение
addImage.submit=Добавить изображение
@@ -1341,7 +1341,7 @@ decrypt.serverError=Ошибка сервера при расшифровке: {
decrypt.success=Файл успешно расшифрован.
#multiTool-advert
multiTool-advert.message=Эта функция также доступна на нашей <a href="{0}">странице мультиинструментов</a>. Ознакомьтесь с расширенным постраничным интерфейсом и дополнительными функциями!
multiTool-advert.message=Эта функция также доступна на нашей <a href="{0}">странице мультиинструмента</a>. Попробуйте её для улучшенного постраничного интерфейса и дополнительных возможностей!
#view pdf
viewPdf.title=Смотреть/Редактировать PDF
@@ -1486,7 +1486,7 @@ changeMetadata.keywords=Ключевые слова:
changeMetadata.modDate=Дата изменения (yyyy/MM/dd HH:mm:ss):
changeMetadata.producer=Производитель:
changeMetadata.subject=Тема:
changeMetadata.trapped=Захвачен:
changeMetadata.trapped=Trapped:
changeMetadata.selectText.4=Другие метаданные:
changeMetadata.selectText.5=Добавить пользовательскую запись метаданных
changeMetadata.submit=Изменить
@@ -1614,15 +1614,15 @@ survey.please=Пожалуйста, примите участие в нашем
survey.disabled=(Всплывающее окно опроса будет отключено в следующих обновлениях, но будет доступно в нижней части страницы)
survey.button=Пройти опрос
survey.dontShowAgain=Больше не показывать
survey.meeting.1=Если вы используете Stirling PDF в своей работе, мы будем рады с вами пообщаться. Мы предлагаем сеансы технической поддержки в обмен на 15-минутный сеанс знакомства с пользователями.
survey.meeting.2=Это возможность:
survey.meeting.3=Получить помощь в развертывании, интеграции или устранении неполадок
survey.meeting.4=Предоставляйте прямые отзывы о производительности, передовых решениях и недостатках в функциях
survey.meeting.5=Помогите нам усовершенствовать Stirling PDF для использования в реальных корпоративных условиях
survey.meeting.6=Если вы заинтересованы, вы можете забронировать время у нашей команды напрямую. (Только для говорящих по-английски)
survey.meeting.7=С нетерпением ждем возможности изучить ваши варианты использования и сделать Stirling PDF еще лучше!
survey.meeting.notInterested=Не занимаетесь бизнесом и/или не заинтересованы во встрече?
survey.meeting.button=Забронировать встречу
survey.meeting.1=If you're using Stirling PDF at work, we'd love to speak to you. We're offering technical support sessions in exchange for a 15 minute user discovery session.
survey.meeting.2=This is a chance to:
survey.meeting.3=Get help with deployment, integrations, or troubleshooting
survey.meeting.4=Provide direct feedback on performance, edge cases, and feature gaps
survey.meeting.5=Help us refine Stirling PDF for real-world enterprise use
survey.meeting.6=If you're interested, you can book time with our team directly. (English speaking only)
survey.meeting.7=Looking forward to digging into your use cases and making Stirling PDF even better!
survey.meeting.notInterested=Not a business and/or interested in a meeting?
survey.meeting.button=Book meeting
#error
error.sorry=Извините за неполадки!
@@ -1664,7 +1664,7 @@ fileChooser.dragAndDropPDF=Перетащите PDF-файл
fileChooser.dragAndDropImage=Перетащите файл изображения
fileChooser.hoveredDragAndDrop=Перетащите файл(ы) сюда
fileChooser.extractPDF=Извлечение...
fileChooser.addAttachments=Перетащите вложения сюда
fileChooser.addAttachments=drag & drop attachments here
#release notes
releases.footer=Релизы
@@ -1709,20 +1709,20 @@ validateSignature.cert.selfSigned=Самоподписанный
validateSignature.cert.bits=бит
# Audit Dashboard
audit.dashboard.title=Панель аудита
audit.dashboard.systemStatus=Состояние системы аудита
audit.dashboard.status=Состояние
audit.dashboard.enabled=Включено
audit.dashboard.disabled=Выключено
audit.dashboard.currentLevel=Текущий уровень
audit.dashboard.retentionPeriod=Срок хранения
audit.dashboard.days=дней
audit.dashboard.totalEvents=Общее количество событий
audit.dashboard.title=Audit Dashboard
audit.dashboard.systemStatus=Audit System Status
audit.dashboard.status=Status
audit.dashboard.enabled=Enabled
audit.dashboard.disabled=Disabled
audit.dashboard.currentLevel=Current Level
audit.dashboard.retentionPeriod=Retention Period
audit.dashboard.days=days
audit.dashboard.totalEvents=Total Events
# Audit Dashboard Tabs
audit.dashboard.tab.dashboard=Панель инструментов
audit.dashboard.tab.events=Аудит событий
audit.dashboard.tab.export=Экспорт
audit.dashboard.tab.dashboard=Dashboard
audit.dashboard.tab.events=Audit Events
audit.dashboard.tab.export=Export
# Dashboard Charts
audit.dashboard.eventsByType=События по типу
audit.dashboard.eventsByUser=События по пользователю
@@ -1732,88 +1732,88 @@ audit.dashboard.period.30days=30 дней
audit.dashboard.period.90days=90 дней
# Events Tab
audit.dashboard.auditEvents=Аудит событий
audit.dashboard.filter.eventType=Тип события
audit.dashboard.filter.allEventTypes=Все типы событий
audit.dashboard.filter.user=Пользователь
audit.dashboard.filter.userPlaceholder=Фильтровать по пользователю
audit.dashboard.filter.startDate=Дата начала
audit.dashboard.filter.endDate=Дата окончания
audit.dashboard.filter.apply=Применить фильтры
audit.dashboard.filter.reset=Сбросить фильтры
audit.dashboard.auditEvents=Audit Events
audit.dashboard.filter.eventType=Event Type
audit.dashboard.filter.allEventTypes=All event types
audit.dashboard.filter.user=User
audit.dashboard.filter.userPlaceholder=Filter by user
audit.dashboard.filter.startDate=Start Date
audit.dashboard.filter.endDate=End Date
audit.dashboard.filter.apply=Apply Filters
audit.dashboard.filter.reset=Reset Filters
# Table Headers
audit.dashboard.table.id=ID
audit.dashboard.table.time=Время
audit.dashboard.table.user=Пользователь
audit.dashboard.table.type=Тип
audit.dashboard.table.details=Подробности
audit.dashboard.table.viewDetails=Просмотр подробной информации
audit.dashboard.table.time=Time
audit.dashboard.table.user=User
audit.dashboard.table.type=Type
audit.dashboard.table.details=Details
audit.dashboard.table.viewDetails=View Details
# Pagination
audit.dashboard.pagination.show=Показать
audit.dashboard.pagination.entries=записи
audit.dashboard.pagination.pageInfo1=Страница
audit.dashboard.pagination.pageInfo2=из
audit.dashboard.pagination.totalRecords=Всего записей:
audit.dashboard.pagination.show=Show
audit.dashboard.pagination.entries=entries
audit.dashboard.pagination.pageInfo1=Page
audit.dashboard.pagination.pageInfo2=of
audit.dashboard.pagination.totalRecords=Total records:
# Modal
audit.dashboard.modal.eventDetails=Подробности события
audit.dashboard.modal.eventDetails=Event Details
audit.dashboard.modal.id=ID
audit.dashboard.modal.user=Пользователь
audit.dashboard.modal.type=Тип
audit.dashboard.modal.time=Время
audit.dashboard.modal.data=Дата
audit.dashboard.modal.user=User
audit.dashboard.modal.type=Type
audit.dashboard.modal.time=Time
audit.dashboard.modal.data=Data
# Export Tab
audit.dashboard.export.title=Экспорт данных аудита
audit.dashboard.export.format=Формат экспорта
audit.dashboard.export.title=Export Audit Data
audit.dashboard.export.format=Export Format
audit.dashboard.export.csv=CSV (Comma Separated Values)
audit.dashboard.export.json=JSON (JavaScript Object Notation)
audit.dashboard.export.button=Экспортировать данные
audit.dashboard.export.infoTitle=Экспорт информации
audit.dashboard.export.infoDesc1=Экспорт будет включать в себя все события аудита, соответствующие выбранным фильтрам. Для создания больших массивов данных экспорт может занять несколько минут.
audit.dashboard.export.infoDesc2=Экспортируемые данные будут включать:
audit.dashboard.export.infoItem1=Идентификатор события
audit.dashboard.export.infoItem2=Пользователь
audit.dashboard.export.infoItem3=Тип события
audit.dashboard.export.infoItem4=Время события
audit.dashboard.export.infoItem5=Данные события
audit.dashboard.export.button=Export Data
audit.dashboard.export.infoTitle=Export Information
audit.dashboard.export.infoDesc1=The export will include all audit events matching the selected filters. For large datasets, the export may take a few moments to generate.
audit.dashboard.export.infoDesc2=Exported data will include:
audit.dashboard.export.infoItem1=Event ID
audit.dashboard.export.infoItem2=User
audit.dashboard.export.infoItem3=Event Type
audit.dashboard.export.infoItem4=Timestamp
audit.dashboard.export.infoItem5=Event Data
# JavaScript i18n keys
audit.dashboard.js.noEventsFound=События аудита, соответствующие текущим фильтрам, не найдены.
audit.dashboard.js.errorLoading=Ошибка загрузки данных:
audit.dashboard.js.errorRendering=Ошибка рендеринга таблицы:
audit.dashboard.js.loadingPage=Загрузка страницы
audit.dashboard.js.noEventsFound=No audit events found matching the current filters
audit.dashboard.js.errorLoading=Error loading data:
audit.dashboard.js.errorRendering=Error rendering table:
audit.dashboard.js.loadingPage=Loading page
####################
# Cookie banner #
####################
cookieBanner.popUp.title=Как мы используем файлы cookie
cookieBanner.popUp.description.1=Мы используем файлы cookie и другие технологии, чтобы улучшить работу Stirling PDF для вас, это помогает нам совершенствовать наши инструменты и создавать функции, которые вам понравятся.
cookieBanner.popUp.description.2=Если вы предпочитаете этого не делать, нажав 'Нет, спасибо', вы активируете только основные файлы cookie, необходимые для бесперебойной работы.
cookieBanner.popUp.acceptAllBtn=Хорошо
cookieBanner.popUp.acceptNecessaryBtn=Нет, спасибо
cookieBanner.popUp.showPreferencesBtn=Управление предпочтениями
cookieBanner.preferencesModal.title=Центр согласия предпочтений
cookieBanner.preferencesModal.acceptAllBtn=Принять все
cookieBanner.preferencesModal.acceptNecessaryBtn=Отклонить все
cookieBanner.preferencesModal.savePreferencesBtn=Сохранить предпочтения
cookieBanner.preferencesModal.closeIconLabel=Закрыть модальное окно
cookieBanner.preferencesModal.serviceCounterLabel=Сервис|Услуги
cookieBanner.preferencesModal.subtitle=Использование файлов cookie
cookieBanner.preferencesModal.description.1=Stirling PDF использует файлы cookie и аналогичные технологии, чтобы улучшить ваш опыт и понять, как используются наши инструменты. Это помогает нам повышать производительность, разрабатывать функции, которые вам интересны, и обеспечивать постоянную поддержку наших пользователей.
cookieBanner.preferencesModal.description.2=Stirling PDF не может — и никогда не будет — отслеживать или получать доступ к содержимому используемых вами документов.
cookieBanner.preferencesModal.description.3=Ваша конфиденциальность и доверие лежат в основе того, что мы делаем.
cookieBanner.preferencesModal.necessary.title.1=Строго необходимые файлы cookie
cookieBanner.preferencesModal.necessary.title.2=Всегда включены
cookieBanner.preferencesModal.necessary.description=Эти файлы cookie необходимы для корректной работы веб-сайта. Они позволяют выполнять основные функции, такие как настройка параметров конфиденциальности, вход в систему и заполнение форм — именно поэтому их нельзя отключить.
cookieBanner.preferencesModal.analytics.title=Аналитика
cookieBanner.preferencesModal.analytics.description=Эти файлы cookie помогают нам понять, как используются наши инструменты, чтобы мы могли сосредоточиться на создании функций, которые больше всего ценятся нашим сообществом. Будьте уверены — Stirling PDF не может и никогда не будет отслеживать содержание документов, с которыми вы работаете.
cookieBanner.popUp.title=How we use Cookies
cookieBanner.popUp.description.1=We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.
cookieBanner.popUp.description.2=If youd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly.
cookieBanner.popUp.acceptAllBtn=Okay
cookieBanner.popUp.acceptNecessaryBtn=No Thanks
cookieBanner.popUp.showPreferencesBtn=Manage preferences
cookieBanner.preferencesModal.title=Consent Preferences Center
cookieBanner.preferencesModal.acceptAllBtn=Accept all
cookieBanner.preferencesModal.acceptNecessaryBtn=Reject all
cookieBanner.preferencesModal.savePreferencesBtn=Save preferences
cookieBanner.preferencesModal.closeIconLabel=Close modal
cookieBanner.preferencesModal.serviceCounterLabel=Service|Services
cookieBanner.preferencesModal.subtitle=Cookie Usage
cookieBanner.preferencesModal.description.1=Stirling PDF uses cookies and similar technologies to enhance your experience and understand how our tools are used. This helps us improve performance, develop the features you care about, and provide ongoing support to our users.
cookieBanner.preferencesModal.description.2=Stirling PDF cannot—and will never—track or access the content of the documents you use.
cookieBanner.preferencesModal.description.3=Your privacy and trust are at the core of what we do.
cookieBanner.preferencesModal.necessary.title.1=Strictly Necessary Cookies
cookieBanner.preferencesModal.necessary.title.2=Always Enabled
cookieBanner.preferencesModal.necessary.description=These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they cant be turned off.
cookieBanner.preferencesModal.analytics.title=Analytics
cookieBanner.preferencesModal.analytics.description=These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with.
#scannerEffect
scannerEffect.title=Эффект сканирования
scannerEffect.header=Эффект сканирования
scannerEffect.title=Поддельное сканирование
scannerEffect.header=Поддельное сканирование
scannerEffect.description=Создайте PDF-файл, который выглядит так, как будто он был отсканирован
scannerEffect.selectPDF=Выбрать PDF:
scannerEffect.quality=Качество сканирования
@@ -1825,12 +1825,12 @@ scannerEffect.rotation.none=Нет
scannerEffect.rotation.slight=Незначительный
scannerEffect.rotation.moderate=Умеренный
scannerEffect.rotation.severe=Сильный
scannerEffect.submit=Создать эффект сканирования
scannerEffect.submit=Создать поддельное сканирование
#home.scannerEffect
home.scannerEffect.title=Эффект сканирования
home.scannerEffect.title=Поддельное сканирование
home.scannerEffect.desc=Создайте PDF-файл, который выглядит так, как будто он был отсканирован
scannerEffect.tags=scan,сканирование,симуляция,имитация,реалистично,преобразование
scannerEffect.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
scannerEffect.advancedSettings=Включите расширенные параметры сканирования
@@ -1849,17 +1849,17 @@ scannerEffect.resolution=Разрешение (DPI)
# Table of Contents Feature
home.editTableOfContents.title=Редактир оглавления
home.editTableOfContents.title=Редактировать оглавление
home.editTableOfContents.desc=Добавление или редактирование закладок и оглавления в PDF-документах
editTableOfContents.tags=закладки, указатель, индекс, оглавление, главы, разделы, схема, навигация, структура
editTableOfContents.title=Редактор оглавления
editTableOfContents.tags=bookmarks,toc,navigation,index,table of contents,chapters,sections,outline
editTableOfContents.title=Редактировать оглавление
editTableOfContents.header=Добавление или редактирование закладок и оглавления в PDF-документах
editTableOfContents.replaceExisting=Заменить существующие закладки (снимите флажок, чтобы добавить к существующим)
editTableOfContents.editorTitle=Редактор закладок
editTableOfContents.editorDesc=Добавьте и упорядочьте закладки ниже. Нажмите «+», чтобы добавить дочерние закладки.
editTableOfContents.addBookmark=Добавить новую закладку
editTableOfContents.desc.1=Этот инструмент позволяет вам добавлять или редактировать оглавление (закладки) в PDF-документе.
editTableOfContents.desc.2=Вы можете создать иерархическую структуру, добавив дочерние закладки к родительским.
editTableOfContents.desc.3=Для каждой закладки требуется название и номер целевой страницы.
editTableOfContents.submit=Применить оглавление
editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to append to existing)
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
editTableOfContents.submit=Apply Table of Contents
@@ -5,135 +5,135 @@
language.direction=ltr
# Language names for reuse throughout the application
lang.afr=南非荷蘭語
lang.amh=阿姆哈拉語
lang.ara=阿拉伯文
lang.asm=阿薩姆語
lang.aze=亞塞拜然語
lang.aze_cyrl=亞塞拜然語(西里爾字母)
lang.bel=白俄羅斯語
lang.ben=孟加拉語
lang.bod=藏文
lang.bos=波士尼亞語
lang.bre=布列塔尼語
lang.bul=保加利亞語
lang.cat=加泰隆尼亞語
lang.ceb=宿霧語
lang.ces=捷克語
lang.chi_sim=簡體中文
lang.chi_sim_vert=簡體中文(直書)
lang.chi_tra=繁體中文
lang.chi_tra_vert=繁體中文(直書)
lang.chr=切羅基語
lang.cos=科西嘉語
lang.cym=威爾斯語
lang.dan=丹麥文
lang.dan_frak=丹麥文(德文尖角體)
lang.deu=德文
lang.deu_frak=德文(德文尖角體)
lang.div=迪維希語
lang.dzo=宗卡文(不丹語)
lang.ell=希臘文
lang.eng=英文
lang.enm=中古英文(約西元 1100-1500 年)
lang.epo=世界語
lang.equ=數學/方程式偵測模組
lang.est=愛沙尼亞語
lang.eus=巴斯克語
lang.fao=法羅語
lang.fas=波斯文
lang.fil=菲律賓語
lang.fin=芬蘭文
lang.fra=法文
lang.frk=法蘭克語
lang.frm=中古法文(約西元 1400-1600 年)
lang.fry=西弗里斯蘭語
lang.gla=蘇格蘭蓋爾語
lang.gle=愛爾蘭語
lang.glg=加利西亞語
lang.grc=古希臘文
lang.guj=古吉拉特語
lang.hat=海地克里奧爾語
lang.heb=希伯來文
lang.hin=印地語
lang.hrv=克羅埃西亞語
lang.hun=匈牙利文
lang.hye=亞美尼亞文
lang.iku=伊努克提圖特語
lang.ind=印尼文
lang.isl=冰島文
lang.ita=義大利文
lang.ita_old=古義大利文
lang.jav=爪哇語
lang.jpn=日文
lang.jpn_vert=日文(直書)
lang.kan=卡納達語
lang.kat=喬治亞語
lang.kat_old=古喬治亞語
lang.kaz=哈薩克語
lang.khm=高棉語
lang.kir=吉爾吉斯語
lang.kmr=北庫德語(庫爾曼吉語)
lang.kor=韓文
lang.kor_vert=韓文(直書)
lang.lao=寮文
lang.lat=拉丁文
lang.lav=拉脫維亞語
lang.lit=立陶宛語
lang.ltz=盧森堡語
lang.mal=馬拉雅拉姆語
lang.mar=馬拉地語
lang.mkd=馬其頓語
lang.mlt=馬爾他語
lang.mon=蒙古文
lang.mri=毛利語
lang.msa=馬來文
lang.mya=緬甸文
lang.nep=尼泊爾語
lang.nld=荷蘭文;佛萊明語
lang.nor=挪威文
lang.oci=奧克西坦語(西元 1500 年後)
lang.ori=奧里亞語
lang.osd=文字方向與書寫系統偵測模組
lang.pan=旁遮普語
lang.pol=波蘭文
lang.por=葡萄牙文
lang.pus=普什圖語
lang.que=克丘亞語
lang.ron=羅馬尼亞語;摩爾多瓦語
lang.rus=俄文
lang.san=梵文
lang.sin=僧伽羅語
lang.slk=斯洛伐克語
lang.slk_frak=斯洛伐克語(德文尖角體)
lang.slv=斯洛維尼亞語
lang.snd=信德語
lang.spa=西班牙文
lang.spa_old=古西班牙文
lang.sqi=阿爾巴尼亞語
lang.srp=塞爾維亞語
lang.srp_latn=塞爾維亞語(拉丁字母)
lang.sun=巽他語
lang.swa=斯瓦希里語
lang.swe=瑞典文
lang.syr=敘利亞語
lang.tam=坦米爾文
lang.tat=韃靼語
lang.tel=泰盧固語
lang.tgk=塔吉克語
lang.tgl=他加祿語
lang.tha=泰文
lang.tir=提格利尼亞語
lang.ton=東加語(東加群島)
lang.tur=土耳其文
lang.uig=維吾爾語
lang.ukr=烏克蘭文
lang.urd=烏爾都語
lang.uzb=烏茲別克語
lang.uzb_cyrl=烏茲別克語(西里爾字母)
lang.vie=越南文
lang.yid=意第緒語
lang.yor=約魯巴語
lang.afr=Afrikaans
lang.amh=Amharic
lang.ara=Arabic
lang.asm=Assamese
lang.aze=Azerbaijani
lang.aze_cyrl=Azerbaijani (Cyrillic)
lang.bel=Belarusian
lang.ben=Bengali
lang.bod=Tibetan
lang.bos=Bosnian
lang.bre=Breton
lang.bul=Bulgarian
lang.cat=Catalan
lang.ceb=Cebuano
lang.ces=Czech
lang.chi_sim=Chinese (Simplified)
lang.chi_sim_vert=Chinese (Simplified, Vertical)
lang.chi_tra=Chinese (Traditional)
lang.chi_tra_vert=Chinese (Traditional, Vertical)
lang.chr=Cherokee
lang.cos=Corsican
lang.cym=Welsh
lang.dan=Danish
lang.dan_frak=Danish (Fraktur)
lang.deu=German
lang.deu_frak=German (Fraktur)
lang.div=Divehi
lang.dzo=Dzongkha
lang.ell=Greek
lang.eng=English
lang.enm=English, Middle (1100-1500)
lang.epo=Esperanto
lang.equ=Math / equation detection module
lang.est=Estonian
lang.eus=Basque
lang.fao=Faroese
lang.fas=Persian
lang.fil=Filipino
lang.fin=Finnish
lang.fra=French
lang.frk=Frankish
lang.frm=French, Middle (ca.1400-1600)
lang.fry=Western Frisian
lang.gla=Scottish Gaelic
lang.gle=Irish
lang.glg=Galician
lang.grc=Ancient Greek
lang.guj=Gujarati
lang.hat=Haitian, Haitian Creole
lang.heb=Hebrew
lang.hin=Hindi
lang.hrv=Croatian
lang.hun=Hungarian
lang.hye=Armenian
lang.iku=Inuktitut
lang.ind=Indonesian
lang.isl=Icelandic
lang.ita=Italian
lang.ita_old=Italian (Old)
lang.jav=Javanese
lang.jpn=Japanese
lang.jpn_vert=Japanese (Vertical)
lang.kan=Kannada
lang.kat=Georgian
lang.kat_old=Georgian (Old)
lang.kaz=Kazakh
lang.khm=Central Khmer
lang.kir=Kirghiz, Kyrgyz
lang.kmr=Northern Kurdish
lang.kor=Korean
lang.kor_vert=Korean (Vertical)
lang.lao=Lao
lang.lat=Latin
lang.lav=Latvian
lang.lit=Lithuanian
lang.ltz=Luxembourgish
lang.mal=Malayalam
lang.mar=Marathi
lang.mkd=Macedonian
lang.mlt=Maltese
lang.mon=Mongolian
lang.mri=Maori
lang.msa=Malay
lang.mya=Burmese
lang.nep=Nepali
lang.nld=Dutch; Flemish
lang.nor=Norwegian
lang.oci=Occitan (post 1500)
lang.ori=Oriya
lang.osd=Orientation and script detection module
lang.pan=Panjabi, Punjabi
lang.pol=Polish
lang.por=Portuguese
lang.pus=Pushto, Pashto
lang.que=Quechua
lang.ron=Romanian, Moldavian, Moldovan
lang.rus=Russian
lang.san=Sanskrit
lang.sin=Sinhala, Sinhalese
lang.slk=Slovak
lang.slk_frak=Slovak (Fraktur)
lang.slv=Slovenian
lang.snd=Sindhi
lang.spa=Spanish
lang.spa_old=Spanish (Old)
lang.sqi=Albanian
lang.srp=Serbian
lang.srp_latn=Serbian (Latin)
lang.sun=Sundanese
lang.swa=Swahili
lang.swe=Swedish
lang.syr=Syriac
lang.tam=Tamil
lang.tat=Tatar
lang.tel=Telugu
lang.tgk=Tajik
lang.tgl=Tagalog
lang.tha=Thai
lang.tir=Tigrinya
lang.ton=Tonga (Tonga Islands)
lang.tur=Turkish
lang.uig=Uighur, Uyghur
lang.ukr=Ukrainian
lang.urd=Urdu
lang.uzb=Uzbek
lang.uzb_cyrl=Uzbek (Cyrillic)
lang.vie=Vietnamese
lang.yid=Yiddish
lang.yor=Yoruba
addPageNumbers.fontSize=字型大小
addPageNumbers.fontName=字型名稱
@@ -170,67 +170,67 @@ sizes.medium=中
sizes.large=
sizes.x-large=特大
error.pdfPassword=PDF 檔案已加密,但未提供密碼或密碼不正確
error.pdfCorrupted=PDF 檔案似乎已毀損或遭到破壞。請先嘗試使用「修復 PDF」功能來修復檔案,然後再繼續此操作。
error.pdfCorruptedMultiple=一個或多個 PDF 檔案似乎已毀損或遭到破壞。請先嘗試對每個檔案使用「修復 PDF」功能,然後再嘗試合併它們。
error.pdfCorruptedDuring=錯誤 {0}PDF 檔案似乎已毀損或遭到破壞。請先嘗試使用「修復 PDF」功能來修復檔案,然後再繼續此操作。
error.pdfCorrupted=PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.
error.pdfCorruptedMultiple=One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them.
error.pdfCorruptedDuring=Error {0}: PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.
# Frontend corruption error messages
error.pdfInvalid=PDF 檔案「{0}」似乎已損毀或結構無效。請先嘗試使用「修復 PDF」功能來修復檔案,然後再繼續。
error.tryRepair=請嘗試使用「修復 PDF」功能來修復損毀的檔案。
error.pdfInvalid=The PDF file "{0}" appears to be corrupted or has an invalid structure. Please try using the 'Repair PDF' feature to fix the file before proceeding.
error.tryRepair=Try using the Repair PDF feature to fix corrupted files.
# Additional error messages
error.pdfEncryption= PDF 的加密資料似乎已損毀。這可能是因為 PDF 是使用不相容的加密方法建立的。請先嘗試使用「修復 PDF」功能,或聯絡文件建立者以取得新副本。
error.fileProcessing=在 {0} 操作期間處理檔案時發生錯誤:{1}
error.pdfEncryption=The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy.
error.fileProcessing=An error occurred while processing the file during {0} operation: {1}
# Generic error message templates
error.toolNotInstalled=未安裝 {0}
error.toolRequired={1} 需要 {0}
error.conversionFailed={0} 轉換失敗
error.commandFailed={0} 指令失敗
error.algorithmNotAvailable=無法使用 {0} 演算法
error.optionsNotSpecified=未指定 {0} 選項
error.fileFormatRequired=檔案必須為 {0} 格式
error.invalidFormat=無效的 {0} 格式:{1}
error.endpointDisabled=此端點已被管理員停用
error.urlNotReachable=無法連線至 URL,請提供有效的 URL
error.toolNotInstalled={0} is not installed
error.toolRequired={0} is required for {1}
error.conversionFailed={0} conversion failed
error.commandFailed={0} command failed
error.algorithmNotAvailable={0} algorithm not available
error.optionsNotSpecified={0} options are not specified
error.fileFormatRequired=File must be in {0} format
error.invalidFormat=Invalid {0} format: {1}
error.endpointDisabled=This endpoint has been disabled by the admin
error.urlNotReachable=URL is not reachable, please provide a valid URL
# DPI and image rendering messages - used by frontend for dynamic translation
# Backend sends: [TRANSLATE:messageKey:arg1,arg2] English message
# Frontend parses this and replaces with localized versions using these keys
error.dpiExceedsLimit=DPI 值 {0} 超出最大安全限制 {1}。高 DPI 值可能導致記憶體問題和當機。請使用較低的 DPI 值。
error.pageTooBigForDpi=PDF 頁面 {0} 太大,無法以 {1} DPI 進行渲染。請嘗試較低的 DPI 值(建議:150 或更低)。
error.pageTooBigExceedsArray=PDF 頁面 {0} 太大,無法以 {1} DPI 進行渲染。產生的影像將超出 Java 的最大陣列大小。請嘗試較低的 DPI 值(建議:150 或更低)。
error.pageTooBigFor300Dpi=PDF 頁面 {0} 太大,無法以 300 DPI 進行渲染。產生的影像將超出 Java 的最大陣列大小。請在 PDF 轉影像時使用較低的 DPI 值。
error.dpiExceedsLimit=DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value.
error.pageTooBigForDpi=PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigExceedsArray=PDF page {0} is too large to render at {1} DPI. The resulting image would exceed Java's maximum array size. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigFor300Dpi=PDF page {0} is too large to render at 300 DPI. The resulting image would exceed Java's maximum array size. Please use a lower DPI value for PDF-to-image conversion.
# URL and website conversion messages
# System requirements messages
# Authentication and security messages
error.apiKeyInvalid=API 金鑰無效。
error.userNotFound=找不到使用者。
error.passwordRequired=密碼不得為空。
error.accountLocked=由於登入失敗次數過多,您的帳號已被鎖定。
error.invalidEmail=提供了無效的電子郵件地址。
error.emailAttachmentRequired=需要附件才能傳送電子郵件。
error.signatureNotFound=找不到簽章檔案。
error.apiKeyInvalid=API key is not valid.
error.userNotFound=User not found.
error.passwordRequired=Password must not be null.
error.accountLocked=Your account has been locked due to too many failed login attempts.
error.invalidEmail=Invalid email addresses provided.
error.emailAttachmentRequired=An attachment is required to send the email.
error.signatureNotFound=Signature file not found.
# File processing messages
error.fileNotFound=找不到 ID {0} 的檔案
error.fileNotFound=File not found with ID: {0}
# Database and configuration messages
error.noBackupScripts=找不到任何備份指令稿。
error.unsupportedProvider=目前不支援 {0}。
error.pathTraversalDetected=因安全因素偵測到路徑遍歷。
error.noBackupScripts=No backup scripts were found.
error.unsupportedProvider={0} is not currently supported.
error.pathTraversalDetected=Path traversal detected for security reasons.
# Validation messages
error.invalidArgument=無效的參數:{0}
error.argumentRequired={0} 不得為空
error.operationFailed=操作失敗:{0}
error.angleNotMultipleOf90=角度必須是 90 的倍數
error.pdfBookmarksNotFound=在文件中找不到 PDF 書籤/大綱
error.fontLoadingFailed=處理字型檔案時發生錯誤
error.fontDirectoryReadFailed=讀取字型目錄失敗
error.invalidArgument=Invalid argument: {0}
error.argumentRequired={0} must not be null
error.operationFailed=Operation failed: {0}
error.angleNotMultipleOf90=Angle must be a multiple of 90
error.pdfBookmarksNotFound=No PDF bookmarks/outline found in document
error.fontLoadingFailed=Error processing font file
error.fontDirectoryReadFailed=Failed to read font directory
delete=刪除
username=使用者名稱
password=密碼
@@ -260,7 +260,7 @@ disabledCurrentUserMessage=無法停用目前使用者
downgradeCurrentUserLongMessage=無法降級目前使用者的角色。因此,將不會顯示目前使用者。
userAlreadyExistsOAuthMessage=使用者已經以 OAuth2 使用者身份存在。
userAlreadyExistsWebMessage=使用者已經以網頁使用者身份存在。
invalidRoleMessage=無效的角色。
invalidRoleMessage=Invalid role.
error=錯誤
oops=哎呀!
help=說明
@@ -273,7 +273,7 @@ color=顏色
sponsor=贊助
info=資訊
pro=專業版
proFeatures=專業版功能
proFeatures=Pro Features
page=頁面
pages=頁面
loading=載入中...
@@ -281,12 +281,12 @@ addToDoc=新增至文件
reset=重設
apply=套用
noFileSelected=未選擇檔案,請上傳一個。
view=檢視
cancel=取消
view=View
cancel=Cancel
back.toSettings=返回設定
back.toHome=返回首頁
back.toAdmin=返回管理員頁面
back.toSettings=Back to Settings
back.toHome=Back to Home
back.toAdmin=Back to Admin
legal.privacy=隱私權政策
legal.terms=使用條款
@@ -327,7 +327,7 @@ enterpriseEdition.button=升級至專業版
enterpriseEdition.warning=此功能僅提供給專業版使用者使用。
enterpriseEdition.yamlAdvert=Stirling PDF 專業版支援 YAML 設定檔和其他單一登入 (SSO) 功能。
enterpriseEdition.ssoAdvert=需要更多使用者管理功能嗎?請參考 Stirling PDF 專業版
enterpriseEdition.proTeamFeatureDisabled=團隊管理功能需要專業版或更進階的授權
enterpriseEdition.proTeamFeatureDisabled=Team management features require a Pro licence or higher
#################
@@ -408,8 +408,8 @@ account.property=屬性
account.webBrowserSettings=網頁瀏覽器設定
account.syncToBrowser=同步帳號 → 瀏覽器
account.syncToAccount=同步帳號 ← 瀏覽器
account.adminTitle=管理員工具
account.adminNotif=您具有管理員權限,可存取系統設定和使用者管理。
account.adminTitle=Administrator Tools
account.adminNotif=You have admin privileges. Access system settings and user management.
adminUserSettings.title=使用者控制設定
@@ -440,48 +440,48 @@ adminUserSettings.disabledUsers=已停用的使用者:
adminUserSettings.totalUsers=使用者總數:
adminUserSettings.lastRequest=最後請求時間
adminUserSettings.usage=檢視使用情況
adminUserSettings.teams=檢視/編輯團隊
adminUserSettings.team=團隊
adminUserSettings.manageTeams=管理團隊
adminUserSettings.createTeam=建立團隊
adminUserSettings.viewTeam=檢視團隊
adminUserSettings.deleteTeam=刪除團隊
adminUserSettings.teamName=團隊名稱
adminUserSettings.teamExists=團隊已存在
adminUserSettings.teamCreated=團隊建立成功
adminUserSettings.teamChanged=使用者的團隊已更新
adminUserSettings.teamHidden=隱藏
adminUserSettings.totalMembers=成員總數
adminUserSettings.confirmDeleteTeam=您確定要刪除此團隊嗎?
adminUserSettings.teams=View/Edit Teams
adminUserSettings.team=Team
adminUserSettings.manageTeams=Manage Teams
adminUserSettings.createTeam=Create Team
adminUserSettings.viewTeam=View Team
adminUserSettings.deleteTeam=Delete Team
adminUserSettings.teamName=Team Name
adminUserSettings.teamExists=Team already exists
adminUserSettings.teamCreated=Team created successfully
adminUserSettings.teamChanged=User's team was updated
adminUserSettings.teamHidden=Hidden
adminUserSettings.totalMembers=Total Members
adminUserSettings.confirmDeleteTeam=Are you sure you want to delete this team?
teamCreated=團隊建立成功
teamExists=該名稱的團隊已存在
teamNameExists=已有同名的團隊存在
teamNotFound=找不到團隊
teamDeleted=團隊已刪除
teamHasUsers=無法刪除已有指派使用者的團隊
teamRenamed=團隊重新命名成功
teamCreated=Team created successfully
teamExists=A team with that name already exists
teamNameExists=Another team with that name already exists
teamNotFound=Team not found
teamDeleted=Team deleted
teamHasUsers=Cannot delete a team with users assigned
teamRenamed=Team renamed successfully
# Team user management
team.addUser=新增使用者至團隊
team.selectUser=選擇使用者
team.warning.moveUser=警告:此操作會將使用者從「{0}」團隊移至「{1}」團隊。您確定嗎?
team.confirm.moveUser=您確定要將此使用者從「{0}」團隊移至「{1}」團隊嗎?
team.userAdded=已成功將使用者新增至團隊
team.back=返回團隊
team.internal=內部團隊
team.internalTeamNotAccessible=內部團隊為系統團隊,無法存取
team.cannotMoveInternalUsers=無法將內部團隊中的使用者移動到其他團隊
team.hidden=隱藏
team.name=團隊名稱
team.totalMembers=成員總數
team.members=成員
team.username=使用者名稱
team.role=角色
team.status=狀態
team.enabled=已啟用
team.disabled=已停用
team.noMembers=此團隊尚無成員。
team.addUser=Add User to Team
team.selectUser=Select User
team.warning.moveUser=Warning: This will move the user from "{0}" team to "{1}" team. Are you sure?
team.confirm.moveUser=Are you sure you want to move this user from "{0}" team to "{1}" team?
team.userAdded=User successfully added to team
team.back=Back to Teams
team.internal=Internal Team
team.internalTeamNotAccessible=The Internal team is a system team and cannot be accessed
team.cannotMoveInternalUsers=Users in the Internal team cannot be moved to other teams
team.hidden=Hidden
team.name=Team Name
team.totalMembers=Total Members
team.members=Members
team.username=Username
team.role=Role
team.status=Status
team.enabled=Enabled
team.disabled=Disabled
team.noMembers=This team has no members yet.
@@ -585,9 +585,9 @@ home.addImage.title=新增圖片
home.addImage.desc=在 PDF 的指定位置新增圖片
addImage.tags=img,jpg,圖片,照片
home.attachments.title=新增附件
home.attachments.desc=將檔案(附件)新增或移除至/從 PDF
attachments.tags=嵌入,附件,檔案,附加,附件管理
home.attachments.title=Add Attachments
home.attachments.desc=Add or remove embedded files (attachments) to/from a PDF
attachments.tags=embed,attach,file,attachment,attachments
home.watermark.title=新增浮水印
home.watermark.desc=在您的 PDF 檔案中新增自訂浮水印。
@@ -715,8 +715,8 @@ home.auto-rename.title=自動重新命名 PDF 檔案
home.auto-rename.desc=根據其偵測到的標頭自動重新命名 PDF 檔案
auto-rename.tags=自動偵測,基於標頭,組織,重新標籤
home.adjust-contrast.title=調整顏色/對比
home.adjust-contrast.desc=調整 PDF 的對比、飽和度和亮度
home.adjust-contrast.title=調整顏色/對比
home.adjust-contrast.desc=調整 PDF 的對比、飽和度和亮度
adjust-contrast.tags=色彩校正,調整,修改,增強
home.crop.title=裁剪 PDF
@@ -834,10 +834,10 @@ home.replaceColorPdf.title=取代與反轉顏色
home.replaceColorPdf.desc=取代 PDF 中文字和背景的顏色,並反轉整個 PDF 的顏色以減少檔案大小
replaceColorPdf.tags=取代顏色,頁面操作,後端,伺服器端
replace-color.selectText.1=取代或反轉顏色選項
replace-color.selectText.2=預設(預設高對比顏色)
replace-color.selectText.2=預設(預設高對比顏色)
replace-color.selectText.3=自訂(自訂顏色)
replace-color.selectText.4=全部反轉(反轉所有顏色)
replace-color.selectText.5=高對比顏色選項
replace-color.selectText.5=高對比顏色選項
replace-color.selectText.6=黑底白字
replace-color.selectText.7=白底黑字
replace-color.selectText.8=黑底黃字
@@ -875,7 +875,7 @@ login.userIsDisabled=使用者已停用,目前此使用者無法登入。請
login.alreadyLoggedIn=您已經登入了
login.alreadyLoggedIn2=部裝置。請先從這些裝置登出後再試一次。
login.toManySessions=您有太多使用中的工作階段
login.logoutMessage=您已登出。
login.logoutMessage=You have been logged out.
#auto-redact
autoRedact.title=自動塗黑
@@ -1059,9 +1059,9 @@ auto-rename.submit=自動重新命名
#adjustContrast
adjustContrast.title=調整對比
adjustContrast.header=調整對比
adjustContrast.contrast=對比:
adjustContrast.title=調整對比
adjustContrast.header=調整對比
adjustContrast.contrast=對比
adjustContrast.brightness=亮度:
adjustContrast.saturation=飽和度:
adjustContrast.download=下載
@@ -1270,11 +1270,11 @@ addImage.upload=新增圖片
addImage.submit=新增圖片
#attachments
attachments.title=新增附件
attachments.header=新增附件
attachments.description=可將附件新增至 PDF
attachments.descriptionPlaceholder=請輸入附件說明...
attachments.addButton=新增附件
attachments.title=Add Attachments
attachments.header=Add attachments
attachments.description=Allows you to add attachments to the PDF
attachments.descriptionPlaceholder=Enter a description for the attachments...
attachments.addButton=Add Attachments
#merge
merge.title=合併
@@ -1282,7 +1282,7 @@ merge.header=合併多個 PDF
merge.sortByName=依名稱排序
merge.sortByDate=依日期排序
merge.removeCertSign=是否移除合併後檔案的憑證簽章?
merge.generateToc=是否在合併後的檔案中產生目錄?
merge.generateToc=Generate table of contents in the merged file?
merge.submit=合併
@@ -1664,7 +1664,7 @@ fileChooser.dragAndDropPDF=拖放 PDF 檔案
fileChooser.dragAndDropImage=拖放圖片檔案
fileChooser.hoveredDragAndDrop=將檔案拖放至此
fileChooser.extractPDF=處理中...
fileChooser.addAttachments=拖曳附件到此處
fileChooser.addAttachments=drag & drop attachments here
#release notes
releases.footer=版本資訊
@@ -1709,157 +1709,157 @@ validateSignature.cert.selfSigned=自我簽署
validateSignature.cert.bits=位元
# Audit Dashboard
audit.dashboard.title=稽核儀表板
audit.dashboard.systemStatus=稽核系統狀態
audit.dashboard.status=狀態
audit.dashboard.enabled=已啟用
audit.dashboard.disabled=已停用
audit.dashboard.currentLevel=目前層級
audit.dashboard.retentionPeriod=保留期間
audit.dashboard.days=
audit.dashboard.totalEvents=事件總數
audit.dashboard.title=Audit Dashboard
audit.dashboard.systemStatus=Audit System Status
audit.dashboard.status=Status
audit.dashboard.enabled=Enabled
audit.dashboard.disabled=Disabled
audit.dashboard.currentLevel=Current Level
audit.dashboard.retentionPeriod=Retention Period
audit.dashboard.days=days
audit.dashboard.totalEvents=Total Events
# Audit Dashboard Tabs
audit.dashboard.tab.dashboard=儀表板
audit.dashboard.tab.events=稽核事件
audit.dashboard.tab.export=匯出
audit.dashboard.tab.dashboard=Dashboard
audit.dashboard.tab.events=Audit Events
audit.dashboard.tab.export=Export
# Dashboard Charts
audit.dashboard.eventsByType=依類型分類的事件
audit.dashboard.eventsByUser=依使用者分類的事件
audit.dashboard.eventsOverTime=事件時間趨勢
audit.dashboard.period.7days=7
audit.dashboard.period.30days=30
audit.dashboard.period.90days=90
audit.dashboard.eventsByType=Events by Type
audit.dashboard.eventsByUser=Events by User
audit.dashboard.eventsOverTime=Events Over Time
audit.dashboard.period.7days=7 Days
audit.dashboard.period.30days=30 Days
audit.dashboard.period.90days=90 Days
# Events Tab
audit.dashboard.auditEvents=稽核事件
audit.dashboard.filter.eventType=事件類型
audit.dashboard.filter.allEventTypes=所有事件類型
audit.dashboard.filter.user=使用者
audit.dashboard.filter.userPlaceholder=依使用者篩選
audit.dashboard.filter.startDate=開始日期
audit.dashboard.filter.endDate=結束日期
audit.dashboard.filter.apply=套用篩選器
audit.dashboard.filter.reset=重設篩選器
audit.dashboard.auditEvents=Audit Events
audit.dashboard.filter.eventType=Event Type
audit.dashboard.filter.allEventTypes=All event types
audit.dashboard.filter.user=User
audit.dashboard.filter.userPlaceholder=Filter by user
audit.dashboard.filter.startDate=Start Date
audit.dashboard.filter.endDate=End Date
audit.dashboard.filter.apply=Apply Filters
audit.dashboard.filter.reset=Reset Filters
# Table Headers
audit.dashboard.table.id=ID
audit.dashboard.table.time=時間
audit.dashboard.table.user=使用者
audit.dashboard.table.type=類型
audit.dashboard.table.details=詳細資訊
audit.dashboard.table.viewDetails=檢視詳細資訊
audit.dashboard.table.time=Time
audit.dashboard.table.user=User
audit.dashboard.table.type=Type
audit.dashboard.table.details=Details
audit.dashboard.table.viewDetails=View Details
# Pagination
audit.dashboard.pagination.show=顯示
audit.dashboard.pagination.entries=個項目
audit.dashboard.pagination.pageInfo1=
audit.dashboard.pagination.pageInfo2=頁,共
audit.dashboard.pagination.totalRecords=總記錄數:
audit.dashboard.pagination.show=Show
audit.dashboard.pagination.entries=entries
audit.dashboard.pagination.pageInfo1=Page
audit.dashboard.pagination.pageInfo2=of
audit.dashboard.pagination.totalRecords=Total records:
# Modal
audit.dashboard.modal.eventDetails=事件詳細資訊
audit.dashboard.modal.eventDetails=Event Details
audit.dashboard.modal.id=ID
audit.dashboard.modal.user=使用者
audit.dashboard.modal.type=類型
audit.dashboard.modal.time=時間
audit.dashboard.modal.data=資料
audit.dashboard.modal.user=User
audit.dashboard.modal.type=Type
audit.dashboard.modal.time=Time
audit.dashboard.modal.data=Data
# Export Tab
audit.dashboard.export.title=匯出稽核資料
audit.dashboard.export.format=匯出格式
audit.dashboard.export.csv=CSV (逗號分隔值)
audit.dashboard.export.json=JSON (JavaScript 物件表示法)
audit.dashboard.export.button=匯出資料
audit.dashboard.export.infoTitle=匯出資訊
audit.dashboard.export.infoDesc1=匯出的內容將包含所有符合所選篩選條件的稽核事件。若資料量龐大,匯出過程可能需要一些時間。
audit.dashboard.export.infoDesc2=匯出的資料將包含:
audit.dashboard.export.infoItem1=事件 ID
audit.dashboard.export.infoItem2=使用者
audit.dashboard.export.infoItem3=事件類型
audit.dashboard.export.infoItem4=時間戳記
audit.dashboard.export.infoItem5=事件資料
audit.dashboard.export.title=Export Audit Data
audit.dashboard.export.format=Export Format
audit.dashboard.export.csv=CSV (Comma Separated Values)
audit.dashboard.export.json=JSON (JavaScript Object Notation)
audit.dashboard.export.button=Export Data
audit.dashboard.export.infoTitle=Export Information
audit.dashboard.export.infoDesc1=The export will include all audit events matching the selected filters. For large datasets, the export may take a few moments to generate.
audit.dashboard.export.infoDesc2=Exported data will include:
audit.dashboard.export.infoItem1=Event ID
audit.dashboard.export.infoItem2=User
audit.dashboard.export.infoItem3=Event Type
audit.dashboard.export.infoItem4=Timestamp
audit.dashboard.export.infoItem5=Event Data
# JavaScript i18n keys
audit.dashboard.js.noEventsFound=找不到符合目前篩選條件的稽核事件
audit.dashboard.js.errorLoading=載入資料時發生錯誤:
audit.dashboard.js.errorRendering=呈現表格時發生錯誤:
audit.dashboard.js.loadingPage=正在載入頁面
audit.dashboard.js.noEventsFound=No audit events found matching the current filters
audit.dashboard.js.errorLoading=Error loading data:
audit.dashboard.js.errorRendering=Error rendering table:
audit.dashboard.js.loadingPage=Loading page
####################
# Cookie banner #
####################
cookieBanner.popUp.title=我們如何使用 Cookie
cookieBanner.popUp.description.1=我們使用 Cookie 和其他技術來讓 Stirling PDF 變得更好、協助我們改進並持續打造您會喜愛的新功能
cookieBanner.popUp.description.2=如果您不希望如此,點選「不,謝謝」只會啟用維持網站正常運作所需的必要 Cookie
cookieBanner.popUp.title=我們如何使用 Cookies
cookieBanner.popUp.description.1=我們使用 Cookies 和其他技術來讓 Stirling PDF 變得更好——幫助我們改善工具並繼續創造您會喜愛的新功能
cookieBanner.popUp.description.2=如果您仍不想,點選「不,謝謝」只會啟必要 Cookies 好讓網站功能保持運作
cookieBanner.popUp.acceptAllBtn=接受
cookieBanner.popUp.acceptNecessaryBtn=不,謝謝
cookieBanner.popUp.showPreferencesBtn=管理偏好設定
cookieBanner.preferencesModal.title=好設定中心
cookieBanner.preferencesModal.title=好設定中心
cookieBanner.preferencesModal.acceptAllBtn=全部接受
cookieBanner.preferencesModal.acceptNecessaryBtn=全部拒絕
cookieBanner.preferencesModal.savePreferencesBtn=儲存設定
cookieBanner.preferencesModal.closeIconLabel=關閉視窗
cookieBanner.preferencesModal.serviceCounterLabel=服務|服務
cookieBanner.preferencesModal.subtitle=Cookie 的用途
cookieBanner.preferencesModal.description.1=Stirling PDF 使用 Cookie 與其他相似技術去改善您的體驗和分析您如何使用我們的工具。這有助於我們改善效能、開發您注目的功能,和提供使用者協助。
cookieBanner.preferencesModal.subtitle=Cookies 的用途
cookieBanner.preferencesModal.description.1=Stirling PDF 使用 Cookies 與其他相似技術去改善您的體驗和分析您如何使用我們的工具。這有助於我們改善效能、開發您注目的功能,和提供使用者協助。
cookieBanner.preferencesModal.description.2=Stirling PDF 不能——且永遠不會——追蹤或存取您的文件。
cookieBanner.preferencesModal.description.3=您的隱私和信任是我們的核心理念。
cookieBanner.preferencesModal.necessary.title.1=必要的 Cookie
cookieBanner.preferencesModal.necessary.title.1=必要的 Cookies
cookieBanner.preferencesModal.necessary.title.2=永遠開啟
cookieBanner.preferencesModal.necessary.description=這些 Cookie 對網站正常運作至關重要。它們讓核心功能,像是隱私設定、登入、填入表格能夠運作——這也是為什麼它們不能被關掉。
cookieBanner.preferencesModal.analytics.title=分析 Cookie
cookieBanner.preferencesModal.analytics.description=這些 Cookie助我們分析您如何使用我們的工具,好讓我們能專注在建構社群最重視的功能。儘管放心—— Stirling PDF 不會且永不追蹤您的文件
cookieBanner.preferencesModal.necessary.description=這些 Cookies 對網站正常運作至關重要。它們讓核心功能,像是隱私設定、登入、填入表格能夠運作——這也是為什麼它們不能被關掉。
cookieBanner.preferencesModal.analytics.title=分析 Cookies
cookieBanner.preferencesModal.analytics.description=這些 Cookies 幫助我們分析您如何使用我們的工具,好讓我們能專注在建構社群最重視的功能。儘管放心—— Stirling PDF 不會且永不追蹤您的文件
#scannerEffect
scannerEffect.title=掃描器效果
scannerEffect.header=掃描器效果
scannerEffect.description=建立看起來像掃描過的 PDF
scannerEffect.selectPDF=選擇 PDF
scannerEffect.quality=掃描品質
scannerEffect.quality.low=
scannerEffect.quality.medium=
scannerEffect.quality.high=
scannerEffect.rotation=旋轉角度
scannerEffect.rotation.none=
scannerEffect.rotation.slight=輕微
scannerEffect.rotation.moderate=中等
scannerEffect.rotation.severe=嚴重
scannerEffect.submit=建立掃描器效果
scannerEffect.title=Scanner Effect
scannerEffect.header=Scanner Effect
scannerEffect.description=Create a PDF that looks like it was scanned
scannerEffect.selectPDF=Select PDF:
scannerEffect.quality=Scan Quality
scannerEffect.quality.low=Low
scannerEffect.quality.medium=Medium
scannerEffect.quality.high=High
scannerEffect.rotation=Rotation Angle
scannerEffect.rotation.none=None
scannerEffect.rotation.slight=Slight
scannerEffect.rotation.moderate=Moderate
scannerEffect.rotation.severe=Severe
scannerEffect.submit=Create Scanner Effect
#home.scannerEffect
home.scannerEffect.title=掃描器效果
home.scannerEffect.desc=建立看起來像掃描過的 PDF
scannerEffect.tags=掃描,模擬,逼真,轉換
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
scannerEffect.advancedSettings=啟用進階掃描設定
scannerEffect.colorspace=色彩空間
scannerEffect.colorspace.grayscale=灰階
scannerEffect.colorspace.color=彩色
scannerEffect.border=邊框 (px)
scannerEffect.rotate=基礎旋轉 (度)
scannerEffect.rotateVariance=旋轉變異 (度)
scannerEffect.brightness=亮度
scannerEffect.contrast=對比
scannerEffect.blur=模糊
scannerEffect.noise=雜訊
scannerEffect.yellowish=泛黃效果 (模擬舊紙張)
scannerEffect.resolution=解析度 (DPI)
scannerEffect.advancedSettings=Enable Advanced Scan Settings
scannerEffect.colorspace=Colorspace
scannerEffect.colorspace.grayscale=Grayscale
scannerEffect.colorspace.color=Color
scannerEffect.border=Border (px)
scannerEffect.rotate=Base Rotation (degrees)
scannerEffect.rotateVariance=Rotation Variance (degrees)
scannerEffect.brightness=Brightness
scannerEffect.contrast=Contrast
scannerEffect.blur=Blur
scannerEffect.noise=Noise
scannerEffect.yellowish=Yellowish (simulate old paper)
scannerEffect.resolution=Resolution (DPI)
# Table of Contents Feature
home.editTableOfContents.title=編輯目錄
home.editTableOfContents.desc=在 PDF 文件中新增或編輯書籤和目錄
home.editTableOfContents.title=Edit Table of Contents
home.editTableOfContents.desc=Add or edit bookmarks and table of contents in PDF documents
editTableOfContents.tags=書籤,toc,導覽,索引,目錄,章節,區段,大綱
editTableOfContents.title=編輯目錄
editTableOfContents.header=新增或編輯 PDF 目錄
editTableOfContents.replaceExisting=取代現有書籤 (取消勾選以附加到現有書籤)
editTableOfContents.editorTitle=書籤編輯器
editTableOfContents.editorDesc=在下方新增和排列書籤。點選 + 新增子書籤。
editTableOfContents.addBookmark=新增書籤
editTableOfContents.desc.1=此工具可讓您在 PDF 文件中新增或編輯目錄 (書籤)。
editTableOfContents.desc.2=您可以透過將子書籤新增至父書籤來建立階層式結構。
editTableOfContents.desc.3=每個書籤都需要標題和目標頁碼。
editTableOfContents.submit=套用目錄
editTableOfContents.tags=bookmarks,toc,navigation,index,table of contents,chapters,sections,outline
editTableOfContents.title=Edit Table of Contents
editTableOfContents.header=Add or Edit PDF Table of Contents
editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to append to existing)
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
editTableOfContents.submit=Apply Table of Contents
@@ -91,7 +91,7 @@ mail:
from: '' # sender email address
legal:
termsAndConditions: https://www.stirlingpdf.com/terms # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
termsAndConditions: https://www.stirlingpdf.com/terms-and-conditions # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
privacyPolicy: https://www.stirlingpdf.com/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
accessibilityStatement: '' # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
cookiePolicy: '' # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
@@ -108,17 +108,6 @@ system:
enableAnalytics: null # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
html:
urlSecurity:
enabled: true # Enable URL security restrictions for HTML processing
level: MEDIUM # Security level: MAX (whitelist only), MEDIUM (block internal networks), OFF (no restrictions)
allowedDomains: [] # Whitelist of allowed domains (e.g. ['cdn.example.com', 'images.google.com'])
blockedDomains: [] # Additional domains to block (e.g. ['evil.com', 'malicious.org'])
internalTlds: ['.local', '.internal', '.corp', '.home'] # Block domains with these TLD patterns
blockPrivateNetworks: true # Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)
blockLocalhost: true # Block localhost and loopback addresses (127.x.x.x, ::1)
blockLinkLocal: true # Block link-local addresses (169.254.x.x, fe80::/10)
blockCloudMetadata: true # Block cloud provider metadata endpoints (169.254.169.254)
datasource:
enableCustomDatabase: false # Enterprise users ONLY, set this property to 'true' if you would like to use your own custom database configuration
customDatabaseUrl: '' # eg jdbc:postgresql://localhost:5432/postgres, set the url for your own custom database connection. If provided, the type, hostName, port and name are not necessary and will not be used
@@ -45,77 +45,77 @@
{
"moduleName": "com.fasterxml.jackson.core:jackson-annotations",
"moduleUrl": "https://github.com/FasterXML/jackson",
"moduleVersion": "2.19.2",
"moduleVersion": "2.19.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.core:jackson-core",
"moduleUrl": "https://github.com/FasterXML/jackson-core",
"moduleVersion": "2.19.2",
"moduleVersion": "2.19.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.core:jackson-databind",
"moduleUrl": "https://github.com/FasterXML/jackson",
"moduleVersion": "2.19.2",
"moduleVersion": "2.19.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml",
"moduleUrl": "https://github.com/FasterXML/jackson-dataformats-text",
"moduleVersion": "2.19.2",
"moduleVersion": "2.19.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.datatype:jackson-datatype-jdk8",
"moduleUrl": "https://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jdk8",
"moduleVersion": "2.19.2",
"moduleVersion": "2.19.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.datatype:jackson-datatype-jsr310",
"moduleUrl": "https://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jsr310",
"moduleVersion": "2.19.2",
"moduleVersion": "2.19.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.jakarta.rs:jackson-jakarta-rs-base",
"moduleUrl": "https://github.com/FasterXML/jackson-jakarta-rs-providers/jackson-jakarta-rs-base",
"moduleVersion": "2.19.2",
"moduleName": "com.fasterxml.jackson.jaxrs:jackson-jaxrs-base",
"moduleUrl": "https://github.com/FasterXML/jackson-jaxrs-providers/jackson-jaxrs-base",
"moduleVersion": "2.19.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.jakarta.rs:jackson-jakarta-rs-json-provider",
"moduleUrl": "https://github.com/FasterXML/jackson-jakarta-rs-providers/jackson-jakarta-rs-json-provider",
"moduleVersion": "2.19.2",
"moduleName": "com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider",
"moduleUrl": "https://github.com/FasterXML/jackson-jaxrs-providers/jackson-jaxrs-json-provider",
"moduleVersion": "2.19.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations",
"moduleName": "com.fasterxml.jackson.module:jackson-module-jaxb-annotations",
"moduleUrl": "https://github.com/FasterXML/jackson-modules-base",
"moduleVersion": "2.19.2",
"moduleVersion": "2.19.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.module:jackson-module-parameter-names",
"moduleUrl": "https://github.com/FasterXML/jackson-modules-java8/jackson-module-parameter-names",
"moduleVersion": "2.19.2",
"moduleVersion": "2.19.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson:jackson-bom",
"moduleUrl": "https://github.com/FasterXML/jackson-bom",
"moduleVersion": "2.19.2",
"moduleVersion": "2.19.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -199,6 +199,13 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.protobuf:protobuf-java",
"moduleUrl": "https://developers.google.com/protocol-buffers/",
"moduleVersion": "4.31.0",
"moduleLicense": "BSD-3-Clause",
"moduleLicenseUrl": "https://opensource.org/licenses/BSD-3-Clause"
},
{
"moduleName": "com.google.zxing:core",
"moduleUrl": "https://github.com/zxing/zxing/core",
@@ -270,7 +277,7 @@
{
"moduleName": "com.opencsv:opencsv",
"moduleUrl": "http://opencsv.sf.net",
"moduleVersion": "5.12.0",
"moduleVersion": "5.11.2",
"moduleLicense": "Apache 2",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -351,14 +358,14 @@
{
"moduleName": "com.unboundid.product.scim2:scim2-sdk-client",
"moduleUrl": "https://github.com/pingidentity/scim2",
"moduleVersion": "4.0.0",
"moduleVersion": "2.3.5",
"moduleLicense": "UnboundID SCIM2 SDK Free Use License",
"moduleLicenseUrl": "https://github.com/pingidentity/scim2"
},
{
"moduleName": "com.unboundid.product.scim2:scim2-sdk-common",
"moduleUrl": "https://github.com/pingidentity/scim2",
"moduleVersion": "4.0.0",
"moduleVersion": "2.3.5",
"moduleLicense": "UnboundID SCIM2 SDK Free Use License",
"moduleLicenseUrl": "https://github.com/pingidentity/scim2"
},
@@ -491,7 +498,7 @@
{
"moduleName": "com.zaxxer:HikariCP",
"moduleUrl": "https://github.com/brettwooldridge/HikariCP",
"moduleVersion": "6.3.1",
"moduleVersion": "6.3.0",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -526,7 +533,7 @@
{
"moduleName": "commons-io:commons-io",
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
"moduleVersion": "2.20.0",
"moduleVersion": "2.19.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -546,71 +553,77 @@
{
"moduleName": "io.micrometer:micrometer-commons",
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
"moduleVersion": "1.15.2",
"moduleVersion": "1.15.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.micrometer:micrometer-core",
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
"moduleVersion": "1.15.2",
"moduleVersion": "1.15.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.micrometer:micrometer-jakarta9",
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
"moduleVersion": "1.15.2",
"moduleVersion": "1.15.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.micrometer:micrometer-observation",
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
"moduleVersion": "1.15.2",
"moduleVersion": "1.15.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.micrometer:micrometer-registry-prometheus",
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
"moduleVersion": "1.15.2",
"moduleVersion": "1.15.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.prometheus:prometheus-metrics-config",
"moduleVersion": "1.3.10",
"moduleVersion": "1.3.8",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.prometheus:prometheus-metrics-core",
"moduleVersion": "1.3.10",
"moduleVersion": "1.3.8",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.prometheus:prometheus-metrics-exposition-formats",
"moduleVersion": "1.3.10",
"moduleVersion": "1.3.8",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.prometheus:prometheus-metrics-exposition-formats-no-protobuf",
"moduleVersion": "1.3.8",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.prometheus:prometheus-metrics-exposition-textformats",
"moduleVersion": "1.3.10",
"moduleVersion": "1.3.8",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.prometheus:prometheus-metrics-model",
"moduleVersion": "1.3.10",
"moduleVersion": "1.3.8",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.prometheus:prometheus-metrics-tracer-common",
"moduleVersion": "1.3.10",
"moduleVersion": "1.3.8",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -739,6 +752,20 @@
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "javax.activation:javax.activation-api",
"moduleUrl": "http://www.oracle.com",
"moduleVersion": "1.2.0",
"moduleLicense": "COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0",
"moduleLicenseUrl": "https://opensource.org/licenses/CDDL-1.0"
},
{
"moduleName": "javax.xml.bind:jaxb-api",
"moduleUrl": "http://www.oracle.com/",
"moduleVersion": "2.3.1",
"moduleLicense": "GPL2 w/ CPE",
"moduleLicenseUrl": "https://oss.oracle.com/licenses/CDDL+GPL-1.1"
},
{
"moduleName": "me.friwi:gluegen-rt",
"moduleUrl": "http://jogamp.org/gluegen/www/",
@@ -912,7 +939,7 @@
{
"moduleName": "org.apache.tomcat.embed:tomcat-embed-el",
"moduleUrl": "https://tomcat.apache.org/",
"moduleVersion": "10.1.43",
"moduleVersion": "10.1.42",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -1021,182 +1048,182 @@
{
"moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-client",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-common",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-server",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jetty-server",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-servlet",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.ee10:jetty-ee10-annotations",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.ee10:jetty-ee10-plus",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.ee10:jetty-ee10-servlet",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.ee10:jetty-ee10-servlets",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.ee10:jetty-ee10-webapp",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.websocket:jetty-websocket-core-client",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.websocket:jetty-websocket-core-common",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.websocket:jetty-websocket-core-server",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.websocket:jetty-websocket-jetty-api",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty.websocket:jetty-websocket-jetty-common",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty:jetty-alpn-client",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty:jetty-client",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty:jetty-ee",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty:jetty-http",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty:jetty-io",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty:jetty-plus",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty:jetty-security",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty:jetty-server",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty:jetty-session",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty:jetty-util",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.eclipse.jetty:jetty-xml",
"moduleUrl": "https://jetty.org/",
"moduleVersion": "12.0.23",
"moduleVersion": "12.0.22",
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
@@ -1238,7 +1265,7 @@
{
"moduleName": "org.hibernate.orm:hibernate-core",
"moduleUrl": "https://www.hibernate.org/orm/6.6",
"moduleVersion": "6.6.22.Final",
"moduleVersion": "6.6.18.Final",
"moduleLicense": "GNU Library General Public License v2.1 or later",
"moduleLicenseUrl": "https://www.opensource.org/licenses/LGPL-2.1"
},
@@ -1430,7 +1457,7 @@
{
"moduleName": "org.snakeyaml:snakeyaml-engine",
"moduleUrl": "https://bitbucket.org/snakeyaml/snakeyaml-engine",
"moduleVersion": "2.10",
"moduleVersion": "2.9",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -1455,196 +1482,196 @@
{
"moduleName": "org.springframework.boot:spring-boot",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-actuator",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-actuator-autoconfigure",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-autoconfigure",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-actuator",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-aop",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-data-jpa",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-jdbc",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-jetty",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-json",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-logging",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-mail",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-oauth2-client",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-security",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-thymeleaf",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-validation",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-web",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleVersion": "3.5.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.data:spring-data-commons",
"moduleUrl": "https://spring.io/projects/spring-data",
"moduleVersion": "3.5.2",
"moduleVersion": "3.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.data:spring-data-jpa",
"moduleUrl": "https://projects.spring.io/spring-data-jpa",
"moduleVersion": "3.5.2",
"moduleVersion": "3.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-config",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "6.5.2",
"moduleVersion": "6.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-core",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "6.5.2",
"moduleVersion": "6.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-crypto",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "6.5.2",
"moduleVersion": "6.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-oauth2-client",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "6.5.2",
"moduleVersion": "6.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-oauth2-core",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "6.5.2",
"moduleVersion": "6.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-oauth2-jose",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "6.5.2",
"moduleVersion": "6.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-saml2-service-provider",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "6.5.2",
"moduleVersion": "6.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-web",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "6.5.2",
"moduleVersion": "6.5.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@@ -1658,91 +1685,91 @@
{
"moduleName": "org.springframework:spring-aop",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-aspects",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-beans",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-context",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-context-support",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-core",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-expression",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-jcl",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-jdbc",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-orm",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-tx",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-web",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework:spring-webmvc",
"moduleUrl": "https://github.com/spring-projects/spring-framework",
"moduleVersion": "6.2.9",
"moduleVersion": "6.2.8",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@@ -1,6 +1,6 @@
/* Main bookmark container styles */
.bookmark-editor {
margin-bottom: 20px;
margin-top: 20px;
padding: 20px;
border: 1px solid var(--border-color, #ced4da);
border-radius: 0.25rem;
@@ -273,4 +273,4 @@
--text-muted: var(--md-sys-color-on-surface-variant, #adb5bd);
--bg-empty: var(--md-sys-color-surface-container-low, #24282e);
--border-empty: var(--md-sys-color-outline, #495057);
}
}
@@ -251,13 +251,6 @@ html[dir="rtl"] .lang-dropdown-item-wrapper {
border-left: 2px solid var(--md-nav-color-on-separator);
}
.scroll-lock-y {
overflow-y: auto;
max-height: 30vh;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
}
/* Responsive adjustments */
@media (min-width: 1200px) {
.lang-dropdown-item-wrapper .dropdown-item {
@@ -265,10 +258,14 @@ html[dir="rtl"] .lang-dropdown-item-wrapper {
}
.scroll-lock-y {
overflow-y: auto;
max-height: 80vh;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
}
}
.dropdown-item .icon-text {
text-wrap: wrap;
word-break: break-word;
+43 -50
View File
@@ -82,62 +82,55 @@ document.querySelector("#navbarSearchInput").addEventListener("input", function
resultsBox.style.width = window.navItemMaxWidth + "px";
});
document.addEventListener('DOMContentLoaded', function () {
const searchDropdown = document.getElementById('searchDropdown');
const searchInput = document.getElementById('navbarSearchInput');
// Check if elements are missing and skip initialization if necessary
if (!searchDropdown || !searchInput) {
console.warn('Search dropdown or input not found. Skipping initialization.');
return;
}
const dropdownMenu = searchDropdown.querySelector('.dropdown-menu');
if (!dropdownMenu) {
console.warn('Dropdown menu not found within the search dropdown. Skipping initialization.');
return;
}
const searchDropdown = document.getElementById('searchDropdown');
const searchInput = document.getElementById('navbarSearchInput');
// Create a single dropdown instance
const dropdownInstance = new bootstrap.Dropdown(searchDropdown);
// Check if elements exist before proceeding
if (searchDropdown && searchInput) {
const dropdownMenu = searchDropdown.querySelector('.dropdown-menu');
// Handle click for mobile
searchDropdown.addEventListener('click', function (e) {
e.preventDefault();
const isOpen = dropdownMenu.classList.contains('show');
// Close all other open dropdowns
document.querySelectorAll('.navbar-nav .dropdown-menu.show').forEach((menu) => {
if (menu !== dropdownMenu) {
const parentDropdown = menu.closest('.dropdown');
if (parentDropdown) {
const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]');
if (parentToggle) {
let instance = bootstrap.Dropdown.getInstance(parentToggle);
if (!instance) {
instance = new bootstrap.Dropdown(parentToggle);
// Create a single dropdown instance
const dropdownInstance = new bootstrap.Dropdown(searchDropdown);
// Handle click for mobile
searchDropdown.addEventListener('click', function (e) {
e.preventDefault();
const isOpen = dropdownMenu.classList.contains('show');
// Close all other open dropdowns
document.querySelectorAll('.navbar-nav .dropdown-menu.show').forEach((menu) => {
if (menu !== dropdownMenu) {
const parentDropdown = menu.closest('.dropdown');
if (parentDropdown) {
const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]');
if (parentToggle) {
let instance = bootstrap.Dropdown.getInstance(parentToggle);
if (!instance) {
instance = new bootstrap.Dropdown(parentToggle);
}
instance.hide();
}
}
}
instance.hide();
}
});
if (!isOpen) {
dropdownInstance.show();
setTimeout(() => searchInput.focus(), 150);
} else {
dropdownInstance.hide();
}
}
});
if (!isOpen) {
dropdownInstance.show();
setTimeout(() => searchInput.focus(), 150);
} else {
dropdownInstance.hide();
}
});
// Hide dropdown if it's open and user clicks outside
document.addEventListener('click', function (e) {
if (!searchDropdown.contains(e.target) && dropdownMenu.classList.contains('show')) {
dropdownInstance.hide();
}
});
// Hide dropdown if it's open and user clicks outside
document.addEventListener('click', function(e) {
if (!searchDropdown.contains(e.target) && dropdownMenu.classList.contains('show')) {
dropdownInstance.hide();
}
});
// Keep dropdown open if search input is clicked
searchInput.addEventListener('click', function (e) {
e.stopPropagation();
});
// Keep dropdown open if search input is clicked
searchInput.addEventListener('click', function (e) {
e.stopPropagation();
});
});
}
@@ -115,7 +115,7 @@
// Set CSS custom property for mobile navbar scaling (for sidebar positioning)
// Use the ACTUAL scaled height, not a fixed assumption
const baseHeight = 64;
const baseHeight = 60;
const actualScaledHeight = baseHeight * navScale;
document.documentElement.style.setProperty('--navbar-height', `${actualScaledHeight}px`);
@@ -200,8 +200,8 @@
<link rel="stylesheet" th:href="@{'/css/footer.css'}">
<link rel="preload" th:href="@{'/fonts/google-symbol.woff2'}" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="stylesheet" th:href="@{'/css/cookieconsent.css'}" th:if="${@analyticsEnabled}">
<link rel="stylesheet" th:href="@{'/css/cookieconsentCustomisation.css'}" th:if="${@analyticsEnabled}">
<link rel="stylesheet" th:href="@{'/css/cookieconsent.css'}">
<link rel="stylesheet" th:href="@{'/css/cookieconsentCustomisation.css'}">
<script th:src="@{'/js/thirdParty/fontfaceobserver.standalone.js'}"></script>
<!-- Google MD Icons -->
@@ -1,6 +1,6 @@
<footer th:fragment="footer" id="footer" class="text-center">
<script type="module" th:src="@{'/js/thirdParty/cookieconsent-config.js'}" th:if="${@analyticsEnabled}"></script>
<script type="module" th:src="@{'/js/thirdParty/cookieconsent-config.js'}"></script>
<div class="footer-center">
<!-- Links section -->
<div class="d-flex justify-content-center">
@@ -13,7 +13,7 @@
<li th:if="${@accessibilityStatement != ''}"><a class="footer-link px-2" target="_blank" th:href="${@accessibilityStatement}" th:text="#{legal.accessibility}">accessibilityStatement</a></li>
<li th:if="${@cookiePolicy != ''}"><a class="footer-link px-2" target="_blank" th:href="${@cookiePolicy}" th:text="#{legal.cookie}">cookiePolicy</a></li>
<li th:if="${@impressum != ''}"><a class="footer-link px-2" target="_blank" th:href="${@impressum}" th:text="#{legal.impressum}">impressum</a></li>
<li th:if="${@analyticsEnabled}"><a class="footer-link px-2" id="cookieBanner" target="_blank" th:text="#{legal.showCookieBanner}" onClick="CookieConsent.show(true)">Cookie Preferences</a></li>
<li><a class="footer-link px-2" id="cookieBanner" target="_blank" th:text="#{legal.showCookieBanner}" onClick="CookieConsent.show(true)">Cookie Preferences</a></li>
</ul>
</div>
@@ -163,7 +163,7 @@
<span class="material-symbols-rounded chevron-icon">expand_more</span>
</a>
<div class="dropdown-menu dropdown-menu-tp" aria-labelledby="searchDropdown">
<div class="dropdown-menu-wrapper px-xl-2 px-2" style="max-width: 95vw !important;">
<div class="dropdown-menu-wrapper px-xl-2 px-2 scroll-lock-y" style="max-width: 95vw !important;">
<form th:action="@{''}" class="d-flex p-2 search-form" id="searchForm">
<input class="form-control search-input" type="search" th:placeholder="#{navbar.search}"
aria-label="Search" id="navbarSearchInput">
@@ -36,11 +36,14 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
class EditTableOfContentsControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock
private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private ObjectMapper objectMapper;
@Mock
private ObjectMapper objectMapper;
@InjectMocks private EditTableOfContentsController editTableOfContentsController;
@InjectMocks
private EditTableOfContentsController editTableOfContentsController;
private MockMultipartFile mockFile;
private PDDocument mockDocument;
@@ -53,9 +56,7 @@ class EditTableOfContentsControllerTest {
@BeforeEach
void setUp() {
mockFile =
new MockMultipartFile(
"file", "test.pdf", "application/pdf", "PDF content".getBytes());
mockFile = new MockMultipartFile("file", "test.pdf", "application/pdf", "PDF content".getBytes());
mockDocument = mock(PDDocument.class);
mockCatalog = mock(PDDocumentCatalog.class);
mockPages = mock(PDPageTree.class);
@@ -148,8 +149,7 @@ class EditTableOfContentsControllerTest {
assertEquals(1, parentBookmark.get("pageNumber"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> children =
(List<Map<String, Object>>) parentBookmark.get("children");
List<Map<String, Object>> children = (List<Map<String, Object>>) parentBookmark.get("children");
assertEquals(1, children.size());
Map<String, Object> childBookmark = children.get(0);
@@ -202,21 +202,17 @@ class EditTableOfContentsControllerTest {
bookmarks.add(bookmark);
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(objectMapper.readValue(eq(request.getBookmarkData()), any(TypeReference.class)))
.thenReturn(bookmarks);
when(objectMapper.readValue(eq(request.getBookmarkData()), any(TypeReference.class))).thenReturn(bookmarks);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockDocument.getNumberOfPages()).thenReturn(5);
when(mockDocument.getPage(0)).thenReturn(mockPage1);
// Mock saving behavior
doAnswer(
invocation -> {
ByteArrayOutputStream baos = invocation.getArgument(0);
baos.write("mocked pdf content".getBytes());
return null;
})
.when(mockDocument)
.save(any(ByteArrayOutputStream.class));
doAnswer(invocation -> {
ByteArrayOutputStream baos = invocation.getArgument(0);
baos.write("mocked pdf content".getBytes());
return null;
}).when(mockDocument).save(any(ByteArrayOutputStream.class));
// When
ResponseEntity<byte[]> result = editTableOfContentsController.editTableOfContents(request);
@@ -225,8 +221,7 @@ class EditTableOfContentsControllerTest {
assertNotNull(result);
assertNotNull(result.getBody());
ArgumentCaptor<PDDocumentOutline> outlineCaptor =
ArgumentCaptor.forClass(PDDocumentOutline.class);
ArgumentCaptor<PDDocumentOutline> outlineCaptor = ArgumentCaptor.forClass(PDDocumentOutline.class);
verify(mockCatalog).setDocumentOutline(outlineCaptor.capture());
PDDocumentOutline capturedOutline = outlineCaptor.getValue();
@@ -241,8 +236,7 @@ class EditTableOfContentsControllerTest {
EditTableOfContentsRequest request = new EditTableOfContentsRequest();
request.setFileInput(mockFile);
String bookmarkJson =
"[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section 1.1\",\"pageNumber\":2,\"children\":[]}]}]";
String bookmarkJson = "[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section 1.1\",\"pageNumber\":2,\"children\":[]}]}]";
request.setBookmarkData(bookmarkJson);
List<BookmarkItem> bookmarks = new ArrayList<>();
@@ -261,21 +255,17 @@ class EditTableOfContentsControllerTest {
bookmarks.add(parentBookmark);
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(objectMapper.readValue(eq(bookmarkJson), any(TypeReference.class)))
.thenReturn(bookmarks);
when(objectMapper.readValue(eq(bookmarkJson), any(TypeReference.class))).thenReturn(bookmarks);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockDocument.getNumberOfPages()).thenReturn(5);
when(mockDocument.getPage(0)).thenReturn(mockPage1);
when(mockDocument.getPage(1)).thenReturn(mockPage2);
doAnswer(
invocation -> {
ByteArrayOutputStream baos = invocation.getArgument(0);
baos.write("mocked pdf content".getBytes());
return null;
})
.when(mockDocument)
.save(any(ByteArrayOutputStream.class));
doAnswer(invocation -> {
ByteArrayOutputStream baos = invocation.getArgument(0);
baos.write("mocked pdf content".getBytes());
return null;
}).when(mockDocument).save(any(ByteArrayOutputStream.class));
// When
ResponseEntity<byte[]> result = editTableOfContentsController.editTableOfContents(request);
@@ -291,8 +281,7 @@ class EditTableOfContentsControllerTest {
// Given
EditTableOfContentsRequest request = new EditTableOfContentsRequest();
request.setFileInput(mockFile);
request.setBookmarkData(
"[{\"title\":\"Chapter 1\",\"pageNumber\":-5,\"children\":[]},{\"title\":\"Chapter 2\",\"pageNumber\":100,\"children\":[]}]");
request.setBookmarkData("[{\"title\":\"Chapter 1\",\"pageNumber\":-5,\"children\":[]},{\"title\":\"Chapter 2\",\"pageNumber\":100,\"children\":[]}]");
List<BookmarkItem> bookmarks = new ArrayList<>();
@@ -310,21 +299,17 @@ class EditTableOfContentsControllerTest {
bookmarks.add(bookmark2);
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(objectMapper.readValue(eq(request.getBookmarkData()), any(TypeReference.class)))
.thenReturn(bookmarks);
when(objectMapper.readValue(eq(request.getBookmarkData()), any(TypeReference.class))).thenReturn(bookmarks);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockDocument.getNumberOfPages()).thenReturn(5);
when(mockDocument.getPage(0)).thenReturn(mockPage1); // For negative page number
when(mockDocument.getPage(4)).thenReturn(mockPage2); // For page number exceeding bounds
doAnswer(
invocation -> {
ByteArrayOutputStream baos = invocation.getArgument(0);
baos.write("mocked pdf content".getBytes());
return null;
})
.when(mockDocument)
.save(any(ByteArrayOutputStream.class));
doAnswer(invocation -> {
ByteArrayOutputStream baos = invocation.getArgument(0);
baos.write("mocked pdf content".getBytes());
return null;
}).when(mockDocument).save(any(ByteArrayOutputStream.class));
// When
ResponseEntity<byte[]> result = editTableOfContentsController.editTableOfContents(request);
@@ -347,20 +332,16 @@ class EditTableOfContentsControllerTest {
when(mockDocument.getPage(2)).thenReturn(mockPage1); // 0-indexed
// When
Method createOutlineItemMethod =
EditTableOfContentsController.class.getDeclaredMethod(
"createOutlineItem", PDDocument.class, BookmarkItem.class);
Method createOutlineItemMethod = EditTableOfContentsController.class.getDeclaredMethod("createOutlineItem", PDDocument.class, BookmarkItem.class);
createOutlineItemMethod.setAccessible(true);
PDOutlineItem result =
(PDOutlineItem)
createOutlineItemMethod.invoke(
editTableOfContentsController, mockDocument, bookmark);
PDOutlineItem result = (PDOutlineItem) createOutlineItemMethod.invoke(editTableOfContentsController, mockDocument, bookmark);
// Then
assertNotNull(result);
verify(mockDocument).getPage(2);
}
@Test
void testBookmarkItem_GettersAndSetters() {
// Given
@@ -384,24 +365,18 @@ class EditTableOfContentsControllerTest {
EditTableOfContentsRequest request = new EditTableOfContentsRequest();
request.setFileInput(mockFile);
when(pdfDocumentFactory.load(mockFile))
.thenThrow(new RuntimeException("Failed to load PDF"));
when(pdfDocumentFactory.load(mockFile)).thenThrow(new RuntimeException("Failed to load PDF"));
// When & Then
assertThrows(
RuntimeException.class,
() -> editTableOfContentsController.editTableOfContents(request));
assertThrows(RuntimeException.class, () -> editTableOfContentsController.editTableOfContents(request));
}
@Test
void testExtractBookmarks_IOExceptionDuringLoad_ThrowsException() throws Exception {
// Given
when(pdfDocumentFactory.load(mockFile))
.thenThrow(new RuntimeException("Failed to load PDF"));
when(pdfDocumentFactory.load(mockFile)).thenThrow(new RuntimeException("Failed to load PDF"));
// When & Then
assertThrows(
RuntimeException.class,
() -> editTableOfContentsController.extractBookmarks(mockFile));
assertThrows(RuntimeException.class, () -> editTableOfContentsController.extractBookmarks(mockFile));
}
}
@@ -29,9 +29,11 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
class MergeControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock
private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks private MergeController mergeController;
@InjectMocks
private MergeController mergeController;
private MockMultipartFile mockFile1;
private MockMultipartFile mockFile2;
@@ -45,15 +47,9 @@ class MergeControllerTest {
@BeforeEach
void setUp() {
mockFile1 =
new MockMultipartFile(
"file1", "document1.pdf", "application/pdf", "PDF content 1".getBytes());
mockFile2 =
new MockMultipartFile(
"file2", "document2.pdf", "application/pdf", "PDF content 2".getBytes());
mockFile3 =
new MockMultipartFile(
"file3", "chapter3.pdf", "application/pdf", "PDF content 3".getBytes());
mockFile1 = new MockMultipartFile("file1", "document1.pdf", "application/pdf", "PDF content 1".getBytes());
mockFile2 = new MockMultipartFile("file2", "document2.pdf", "application/pdf", "PDF content 2".getBytes());
mockFile3 = new MockMultipartFile("file3", "chapter3.pdf", "application/pdf", "PDF content 3".getBytes());
mockDocument = mock(PDDocument.class);
mockMergedDocument = mock(PDDocument.class);
@@ -89,15 +85,12 @@ class MergeControllerTest {
when(doc3.getNumberOfPages()).thenReturn(2);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
ArgumentCaptor<PDDocumentOutline> outlineCaptor =
ArgumentCaptor.forClass(PDDocumentOutline.class);
ArgumentCaptor<PDDocumentOutline> outlineCaptor = ArgumentCaptor.forClass(PDDocumentOutline.class);
verify(mockCatalog).setDocumentOutline(outlineCaptor.capture());
PDDocumentOutline capturedOutline = outlineCaptor.getValue();
@@ -128,9 +121,7 @@ class MergeControllerTest {
when(doc1.getNumberOfPages()).thenReturn(3);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
@@ -147,9 +138,7 @@ class MergeControllerTest {
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
@@ -166,8 +155,7 @@ class MergeControllerTest {
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(4);
when(mockMergedDocument.getPage(anyInt()))
.thenReturn(mockPage1); // Use anyInt() to avoid stubbing conflicts
when(mockMergedDocument.getPage(anyInt())).thenReturn(mockPage1); // Use anyInt() to avoid stubbing conflicts
// First document loads successfully
PDDocument doc1 = mock(PDDocument.class);
@@ -175,18 +163,16 @@ class MergeControllerTest {
when(doc1.getNumberOfPages()).thenReturn(2);
// Second document throws IOException
when(pdfDocumentFactory.load(mockFile2))
.thenThrow(new IOException("Failed to load document"));
when(pdfDocumentFactory.load(mockFile2)).thenThrow(new IOException("Failed to load document"));
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
// Should not throw exception
assertDoesNotThrow(
() -> addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files));
assertDoesNotThrow(() ->
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files)
);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
@@ -198,9 +184,7 @@ class MergeControllerTest {
@Test
void testAddTableOfContents_FilenameWithoutExtension_UsesFullName() throws Exception {
// Given
MockMultipartFile fileWithoutExtension =
new MockMultipartFile(
"file", "document_no_ext", "application/pdf", "PDF content".getBytes());
MockMultipartFile fileWithoutExtension = new MockMultipartFile("file", "document_no_ext", "application/pdf", "PDF content".getBytes());
MultipartFile[] files = {fileWithoutExtension};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
@@ -212,9 +196,7 @@ class MergeControllerTest {
when(doc.getNumberOfPages()).thenReturn(1);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
@@ -236,14 +218,13 @@ class MergeControllerTest {
when(doc1.getNumberOfPages()).thenReturn(3);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
// Should not throw exception
assertDoesNotThrow(
() -> addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files));
assertDoesNotThrow(() ->
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files)
);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
@@ -294,4 +275,5 @@ class MergeControllerTest {
assertEquals(mockMergedDocument, result);
verify(mockMergedDocument, never()).addPage(any(PDPage.class));
}
}
@@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import org.mockito.MockedStatic;
import java.io.IOException;
import java.util.List;
@@ -13,7 +15,6 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -28,11 +29,14 @@ import stirling.software.common.util.WebResponseUtils;
@ExtendWith(MockitoExtension.class)
class AttachmentControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock
private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private AttachmentServiceInterface pdfAttachmentService;
@Mock
private AttachmentServiceInterface pdfAttachmentService;
@InjectMocks private AttachmentController attachmentController;
@InjectMocks
private AttachmentController attachmentController;
private MockMultipartFile pdfFile;
private MockMultipartFile attachment1;
@@ -43,15 +47,9 @@ class AttachmentControllerTest {
@BeforeEach
void setUp() {
pdfFile =
new MockMultipartFile(
"fileInput", "test.pdf", "application/pdf", "PDF content".getBytes());
attachment1 =
new MockMultipartFile(
"attachment1", "file1.txt", "text/plain", "File 1 content".getBytes());
attachment2 =
new MockMultipartFile(
"attachment2", "file2.jpg", "image/jpeg", "Image content".getBytes());
pdfFile = new MockMultipartFile("fileInput", "test.pdf", "application/pdf", "PDF content".getBytes());
attachment1 = new MockMultipartFile("attachment1", "file1.txt", "text/plain", "File 1 content".getBytes());
attachment2 = new MockMultipartFile("attachment2", "file2.jpg", "image/jpeg", "Image content".getBytes());
request = new AddAttachmentRequest();
mockDocument = mock(PDDocument.class);
modifiedMockDocument = mock(PDDocument.class);
@@ -62,21 +60,13 @@ class AttachmentControllerTest {
List<MultipartFile> attachments = List.of(attachment1, attachment2);
request.setAttachments(attachments);
request.setFileInput(pdfFile);
ResponseEntity<byte[]> expectedResponse =
ResponseEntity.ok("modified PDF content".getBytes());
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok("modified PDF content".getBytes());
when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument);
when(pdfAttachmentService.addAttachment(mockDocument, attachments))
.thenReturn(modifiedMockDocument);
when(pdfAttachmentService.addAttachment(mockDocument, attachments)).thenReturn(modifiedMockDocument);
try (MockedStatic<WebResponseUtils> mockedWebResponseUtils =
mockStatic(WebResponseUtils.class)) {
mockedWebResponseUtils
.when(
() ->
WebResponseUtils.pdfDocToWebResponse(
eq(modifiedMockDocument),
eq("test_with_attachments.pdf")))
try (MockedStatic<WebResponseUtils> mockedWebResponseUtils = mockStatic(WebResponseUtils.class)) {
mockedWebResponseUtils.when(() -> WebResponseUtils.pdfDocToWebResponse(eq(modifiedMockDocument), eq("test_with_attachments.pdf")))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = attachmentController.addAttachments(request);
@@ -94,21 +84,13 @@ class AttachmentControllerTest {
List<MultipartFile> attachments = List.of(attachment1);
request.setAttachments(attachments);
request.setFileInput(pdfFile);
ResponseEntity<byte[]> expectedResponse =
ResponseEntity.ok("modified PDF content".getBytes());
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok("modified PDF content".getBytes());
when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument);
when(pdfAttachmentService.addAttachment(mockDocument, attachments))
.thenReturn(modifiedMockDocument);
when(pdfAttachmentService.addAttachment(mockDocument, attachments)).thenReturn(modifiedMockDocument);
try (MockedStatic<WebResponseUtils> mockedWebResponseUtils =
mockStatic(WebResponseUtils.class)) {
mockedWebResponseUtils
.when(
() ->
WebResponseUtils.pdfDocToWebResponse(
eq(modifiedMockDocument),
eq("test_with_attachments.pdf")))
try (MockedStatic<WebResponseUtils> mockedWebResponseUtils = mockStatic(WebResponseUtils.class)) {
mockedWebResponseUtils.when(() -> WebResponseUtils.pdfDocToWebResponse(eq(modifiedMockDocument), eq("test_with_attachments.pdf")))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = attachmentController.addAttachments(request);
@@ -20,11 +20,11 @@ import org.springframework.http.ResponseEntity;
import jakarta.servlet.ServletContext;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.service.ApiDocService;
import stirling.software.common.service.UserServiceInterface;
@ExtendWith(MockitoExtension.class)
class PipelineProcessorTest {
@@ -45,26 +45,23 @@ class PipelineProcessorTest {
@Test
void runPipelineWithFilterSetsFlag() throws Exception {
PipelineOperation op = new PipelineOperation();
op.setOperation("/api/v1/filter/filter-page-count");
op.setOperation("filter-page-count");
op.setParameters(Map.of());
PipelineConfig config = new PipelineConfig();
config.setOperations(List.of(op));
Resource file =
new ByteArrayResource("data".getBytes()) {
@Override
public String getFilename() {
return "test.pdf";
}
};
Resource file = new ByteArrayResource("data".getBytes()) {
@Override
public String getFilename() {
return "test.pdf";
}
};
List<Resource> files = List.of(file);
when(apiDocService.isMultiInput("/api/v1/filter/filter-page-count")).thenReturn(false);
when(apiDocService.getExtensionTypes(false, "/api/v1/filter/filter-page-count"))
when(apiDocService.isMultiInput("filter-page-count")).thenReturn(false);
when(apiDocService.getExtensionTypes(false, "filter-page-count"))
.thenReturn(List.of("pdf"));
when(apiDocService.isValidOperation(eq("/api/v1/filter/filter-page-count"), anyMap()))
.thenReturn(true);
doReturn(new ResponseEntity<>(new byte[0], HttpStatus.OK))
.when(pipelineProcessor)
@@ -1,17 +1,15 @@
package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.multipart.MultipartFile;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AttachmentServiceTest {
@@ -29,8 +27,8 @@ class AttachmentServiceTest {
var attachments = List.of(mock(MultipartFile.class));
when(attachments.get(0).getOriginalFilename()).thenReturn("test.txt");
when(attachments.get(0).getInputStream())
.thenReturn(new ByteArrayInputStream("Test content".getBytes()));
when(attachments.get(0).getInputStream()).thenReturn(
new ByteArrayInputStream("Test content".getBytes()));
when(attachments.get(0).getSize()).thenReturn(12L);
when(attachments.get(0).getContentType()).thenReturn("text/plain");
@@ -51,14 +49,14 @@ class AttachmentServiceTest {
var attachments = List.of(attachment1, attachment2);
when(attachment1.getOriginalFilename()).thenReturn("document.pdf");
when(attachment1.getInputStream())
.thenReturn(new ByteArrayInputStream("PDF content".getBytes()));
when(attachment1.getInputStream()).thenReturn(
new ByteArrayInputStream("PDF content".getBytes()));
when(attachment1.getSize()).thenReturn(15L);
when(attachment1.getContentType()).thenReturn("application/pdf");
when(attachment2.getOriginalFilename()).thenReturn("image.jpg");
when(attachment2.getInputStream())
.thenReturn(new ByteArrayInputStream("Image content".getBytes()));
when(attachment2.getInputStream()).thenReturn(
new ByteArrayInputStream("Image content".getBytes()));
when(attachment2.getSize()).thenReturn(20L);
when(attachment2.getContentType()).thenReturn("image/jpeg");
@@ -76,8 +74,8 @@ class AttachmentServiceTest {
var attachments = List.of(mock(MultipartFile.class));
when(attachments.get(0).getOriginalFilename()).thenReturn("image.jpg");
when(attachments.get(0).getInputStream())
.thenReturn(new ByteArrayInputStream("Image content".getBytes()));
when(attachments.get(0).getInputStream()).thenReturn(
new ByteArrayInputStream("Image content".getBytes()));
when(attachments.get(0).getSize()).thenReturn(25L);
when(attachments.get(0).getContentType()).thenReturn("");
@@ -17,8 +17,8 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.SPDF.model.SignatureFile;
class SignatureServiceTest {
@@ -12,28 +12,36 @@ import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.TaskManager;
class JobControllerTest {
@Mock private TaskManager taskManager;
@Mock
private TaskManager taskManager;
@Mock private FileStorage fileStorage;
@Mock
private FileStorage fileStorage;
@Mock private JobQueue jobQueue;
@Mock
private JobQueue jobQueue;
@Mock private HttpServletRequest request;
@Mock
private HttpServletRequest request;
private MockHttpSession session;
@InjectMocks private JobController controller;
@InjectMocks
private JobController controller;
@BeforeEach
void setUp() {
@@ -131,8 +139,7 @@ class JobControllerTest {
JobResult mockResult = new JobResult();
mockResult.setJobId(jobId);
mockResult.completeWithSingleFile(
fileId, originalFileName, contentType, fileContent.length);
mockResult.completeWithSingleFile(fileId, originalFileName, contentType, fileContent.length);
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
when(fileStorage.retrieveBytes(fileId)).thenReturn(fileContent);
@@ -143,8 +150,7 @@ class JobControllerTest {
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(contentType, response.getHeaders().getFirst("Content-Type"));
assertTrue(
response.getHeaders().getFirst("Content-Disposition").contains(originalFileName));
assertTrue(response.getHeaders().getFirst("Content-Disposition").contains(originalFileName));
assertEquals(fileContent, response.getBody());
}
@@ -223,45 +229,45 @@ class JobControllerTest {
assertTrue(response.getBody().toString().contains("Error retrieving file"));
}
/*
* @Test void testGetJobStats() { // Arrange JobStats mockStats =
* JobStats.builder() .totalJobs(10) .activeJobs(3) .completedJobs(7) .build();
*
* when(taskManager.getJobStats()).thenReturn(mockStats);
*
* // Act ResponseEntity<?> response = controller.getJobStats();
*
* // Assert assertEquals(HttpStatus.OK, response.getStatusCode());
* assertEquals(mockStats, response.getBody()); }
*
* @Test void testCleanupOldJobs() { // Arrange when(taskManager.getJobStats())
* .thenReturn(JobStats.builder().totalJobs(10).build())
* .thenReturn(JobStats.builder().totalJobs(7).build());
*
* // Act ResponseEntity<?> response = controller.cleanupOldJobs();
*
* // Assert assertEquals(HttpStatus.OK, response.getStatusCode());
*
* @SuppressWarnings("unchecked") Map<String, Object> responseBody =
* (Map<String, Object>) response.getBody(); assertEquals("Cleanup complete",
* responseBody.get("message")); assertEquals(3,
* responseBody.get("removedJobs")); assertEquals(7,
* responseBody.get("remainingJobs"));
*
* verify(taskManager).cleanupOldJobs(); }
*
* @Test void testGetQueueStats() { // Arrange Map<String, Object>
* mockQueueStats = Map.of( "queuedJobs", 5, "queueCapacity", 10,
* "resourceStatus", "OK" );
*
* when(jobQueue.getQueueStats()).thenReturn(mockQueueStats);
*
* // Act ResponseEntity<?> response = controller.getQueueStats();
*
* // Assert assertEquals(HttpStatus.OK, response.getStatusCode());
* assertEquals(mockQueueStats, response.getBody());
* verify(jobQueue).getQueueStats(); }
*/
/*
* @Test void testGetJobStats() { // Arrange JobStats mockStats =
* JobStats.builder() .totalJobs(10) .activeJobs(3) .completedJobs(7) .build();
*
* when(taskManager.getJobStats()).thenReturn(mockStats);
*
* // Act ResponseEntity<?> response = controller.getJobStats();
*
* // Assert assertEquals(HttpStatus.OK, response.getStatusCode());
* assertEquals(mockStats, response.getBody()); }
*
* @Test void testCleanupOldJobs() { // Arrange when(taskManager.getJobStats())
* .thenReturn(JobStats.builder().totalJobs(10).build())
* .thenReturn(JobStats.builder().totalJobs(7).build());
*
* // Act ResponseEntity<?> response = controller.cleanupOldJobs();
*
* // Assert assertEquals(HttpStatus.OK, response.getStatusCode());
*
* @SuppressWarnings("unchecked") Map<String, Object> responseBody =
* (Map<String, Object>) response.getBody(); assertEquals("Cleanup complete",
* responseBody.get("message")); assertEquals(3,
* responseBody.get("removedJobs")); assertEquals(7,
* responseBody.get("remainingJobs"));
*
* verify(taskManager).cleanupOldJobs(); }
*
* @Test void testGetQueueStats() { // Arrange Map<String, Object>
* mockQueueStats = Map.of( "queuedJobs", 5, "queueCapacity", 10,
* "resourceStatus", "OK" );
*
* when(jobQueue.getQueueStats()).thenReturn(mockQueueStats);
*
* // Act ResponseEntity<?> response = controller.getQueueStats();
*
* // Assert assertEquals(HttpStatus.OK, response.getStatusCode());
* assertEquals(mockQueueStats, response.getBody());
* verify(jobQueue).getQueueStats(); }
*/
@Test
void testCancelJob_InQueue() {
// Arrange
+2 -17
View File
@@ -5,8 +5,8 @@ bootRun {
enabled = false
}
spotless {
java {
target 'src/**/java/**/*.java'
java {
target sourceSets.main.allJava
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
@@ -15,18 +15,6 @@ spotless {
leadingTabsToSpaces()
endWithNewline()
}
yaml {
target '**/*.yml', '**/*.yaml'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
format 'gradle', {
target '**/gradle/*.gradle', '**/*.gradle'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
}
dependencies {
implementation project(':common')
@@ -56,9 +44,6 @@ dependencies {
implementation "org.opensaml:opensaml-core:$openSamlVersion"
implementation "org.opensaml:opensaml-saml-api:$openSamlVersion"
implementation "org.opensaml:opensaml-saml-impl:$openSamlVersion"
// Security vulnerability fixes - remove when parent dependencies update
implementation 'com.nimbusds:nimbus-jose-jwt:10.0.2' // CVE-2025-53864 - from spring-boot-starter-oauth2-client
implementation 'com.google.guava:guava:33.4.8-jre' // CVE-2023-2976, CVE-2020-8908 - from OpenSAML dependencies above
}
implementation 'com.coveo:saml-client:5.0.0'
}
@@ -1,633 +0,0 @@
package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.util.HtmlUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
@Controller
@Tag(name = "Admin Settings", description = "Admin-only Settings Management APIs")
@RequestMapping("/api/v1/admin/settings")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Slf4j
public class AdminSettingsController {
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
// Track settings that have been modified but not yet applied (require restart)
private static final ConcurrentHashMap<String, Object> pendingChanges =
new ConcurrentHashMap<>();
// Define specific sensitive field names that contain secret values
private static final Set<String> SENSITIVE_FIELD_NAMES =
new HashSet<>(
Arrays.asList(
// Passwords
"password",
"dbpassword",
"mailpassword",
"smtppassword",
// OAuth/API secrets
"clientsecret",
"apisecret",
"secret",
// API tokens
"apikey",
"accesstoken",
"refreshtoken",
"token",
// Specific secret keys (not all keys, and excluding premium.key)
"key", // automaticallyGenerated.key
"enterprisekey",
"licensekey"));
@GetMapping
@Operation(
summary = "Get all application settings",
description =
"Retrieve all current application settings. Use includePending=true to include settings that will take effect after restart. Admin access required.")
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "Settings retrieved successfully"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettings(
@RequestParam(value = "includePending", defaultValue = "false")
boolean includePending) {
log.debug("Admin requested all application settings (includePending={})", includePending);
// Convert ApplicationProperties to Map
Map<String, Object> settings = objectMapper.convertValue(applicationProperties, Map.class);
if (includePending && !pendingChanges.isEmpty()) {
// Merge pending changes into the settings map
settings = mergePendingChanges(settings, pendingChanges);
}
// Mask sensitive fields after merging
Map<String, Object> maskedSettings = maskSensitiveFields(settings);
return ResponseEntity.ok(maskedSettings);
}
@GetMapping("/delta")
@Operation(
summary = "Get pending settings changes",
description =
"Retrieve settings that have been modified but not yet applied (require restart). Admin access required.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "Pending changes retrieved successfully"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettingsDelta() {
Map<String, Object> response = new HashMap<>();
// Mask sensitive fields in pending changes
response.put("pendingChanges", maskSensitiveFields(new HashMap<>(pendingChanges)));
response.put("hasPendingChanges", !pendingChanges.isEmpty());
response.put("count", pendingChanges.size());
log.debug("Admin requested pending changes - found {} settings", pendingChanges.size());
return ResponseEntity.ok(response);
}
@PutMapping
@Operation(
summary = "Update application settings (delta updates)",
description =
"Update specific application settings using dot notation keys. Only sends changed values. Changes take effect on restart. Admin access required.")
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "Settings updated successfully"),
@ApiResponse(responseCode = "400", description = "Invalid setting key or value"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required"),
@ApiResponse(
responseCode = "500",
description = "Failed to save settings to configuration file")
})
public ResponseEntity<String> updateSettings(
@Valid @RequestBody UpdateSettingsRequest request) {
try {
Map<String, Object> settings = request.getSettings();
if (settings == null || settings.isEmpty()) {
return ResponseEntity.badRequest().body("No settings provided to update");
}
int updatedCount = 0;
for (Map.Entry<String, Object> entry : settings.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (!isValidSettingKey(key)) {
return ResponseEntity.badRequest()
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
}
log.info("Admin updating setting: {} = {}", key, value);
GeneralUtils.saveKeyToSettings(key, value);
// Track this as a pending change
pendingChanges.put(key, value);
updatedCount++;
}
return ResponseEntity.ok(
String.format(
"Successfully updated %d setting(s). Changes will take effect on application restart.",
updatedCount));
} catch (IOException e) {
log.error("Failed to save settings to file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
} catch (IllegalArgumentException e) {
log.error("Invalid setting key or value: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SETTING);
} catch (Exception e) {
log.error("Unexpected error while updating settings: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(GENERIC_SERVER_ERROR);
}
}
@GetMapping("/section/{sectionName}")
@Operation(
summary = "Get specific settings section",
description =
"Retrieve settings for a specific section (e.g., security, system, ui). Admin access required.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "Section settings retrieved successfully"),
@ApiResponse(responseCode = "400", description = "Invalid section name"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettingsSection(@PathVariable String sectionName) {
try {
Object sectionData = getSectionData(sectionName);
if (sectionData == null) {
return ResponseEntity.badRequest()
.body(
"Invalid section name: "
+ HtmlUtils.htmlEscape(sectionName)
+ ". Valid sections: "
+ String.join(", ", VALID_SECTION_NAMES));
}
log.debug("Admin requested settings section: {}", sectionName);
return ResponseEntity.ok(sectionData);
} catch (IllegalArgumentException e) {
log.error("Invalid section name {}: {}", sectionName, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Invalid section name: " + HtmlUtils.htmlEscape(sectionName));
} catch (Exception e) {
log.error("Error retrieving section {}: {}", sectionName, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to retrieve section.");
}
}
@PutMapping("/section/{sectionName}")
@Operation(
summary = "Update specific settings section",
description = "Update all settings within a specific section. Admin access required.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "Section settings updated successfully"),
@ApiResponse(responseCode = "400", description = "Invalid section name or data"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required"),
@ApiResponse(responseCode = "500", description = "Failed to save settings")
})
public ResponseEntity<String> updateSettingsSection(
@PathVariable String sectionName, @Valid @RequestBody Map<String, Object> sectionData) {
try {
if (sectionData == null || sectionData.isEmpty()) {
return ResponseEntity.badRequest().body("No section data provided to update");
}
if (!isValidSectionName(sectionName)) {
return ResponseEntity.badRequest()
.body(
"Invalid section name: "
+ HtmlUtils.htmlEscape(sectionName)
+ ". Valid sections: "
+ String.join(", ", VALID_SECTION_NAMES));
}
int updatedCount = 0;
for (Map.Entry<String, Object> entry : sectionData.entrySet()) {
String propertyKey = entry.getKey();
String fullKey = sectionName + "." + propertyKey;
Object value = entry.getValue();
if (!isValidSettingKey(fullKey)) {
return ResponseEntity.badRequest()
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(fullKey));
}
log.info("Admin updating section setting: {} = {}", fullKey, value);
GeneralUtils.saveKeyToSettings(fullKey, value);
// Track this as a pending change
pendingChanges.put(fullKey, value);
updatedCount++;
}
String escapedSectionName = HtmlUtils.htmlEscape(sectionName);
return ResponseEntity.ok(
String.format(
"Successfully updated %d setting(s) in section '%s'. Changes will take effect on application restart.",
updatedCount, escapedSectionName));
} catch (IOException e) {
log.error("Failed to save section settings to file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
} catch (IllegalArgumentException e) {
log.error("Invalid section data: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SECTION);
} catch (Exception e) {
log.error("Unexpected error while updating section settings: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(GENERIC_SERVER_ERROR);
}
}
@GetMapping("/key/{key}")
@Operation(
summary = "Get specific setting value",
description =
"Retrieve value for a specific setting key using dot notation. Admin access required.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "Setting value retrieved successfully"),
@ApiResponse(responseCode = "400", description = "Invalid setting key"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettingValue(@PathVariable String key) {
try {
if (!isValidSettingKey(key)) {
return ResponseEntity.badRequest()
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
}
Object value = getSettingByKey(key);
if (value == null) {
return ResponseEntity.badRequest()
.body("Setting key not found: " + HtmlUtils.htmlEscape(key));
}
log.debug("Admin requested setting: {}", key);
return ResponseEntity.ok(new SettingValueResponse(key, value));
} catch (IllegalArgumentException e) {
log.error("Invalid setting key {}: {}", key, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Invalid setting key: " + HtmlUtils.htmlEscape(key));
} catch (Exception e) {
log.error("Error retrieving setting {}: {}", key, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to retrieve setting.");
}
}
@PutMapping("/key/{key}")
@Operation(
summary = "Update specific setting value",
description =
"Update value for a specific setting key using dot notation. Admin access required.")
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "Setting updated successfully"),
@ApiResponse(responseCode = "400", description = "Invalid setting key or value"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required"),
@ApiResponse(responseCode = "500", description = "Failed to save setting")
})
public ResponseEntity<String> updateSettingValue(
@PathVariable String key, @Valid @RequestBody UpdateSettingValueRequest request) {
try {
if (!isValidSettingKey(key)) {
return ResponseEntity.badRequest()
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
}
Object value = request.getValue();
log.info("Admin updating single setting: {} = {}", key, value);
GeneralUtils.saveKeyToSettings(key, value);
// Track this as a pending change
pendingChanges.put(key, value);
String escapedKey = HtmlUtils.htmlEscape(key);
return ResponseEntity.ok(
String.format(
"Successfully updated setting '%s'. Changes will take effect on application restart.",
escapedKey));
} catch (IOException e) {
log.error("Failed to save setting to file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
} catch (IllegalArgumentException e) {
log.error("Invalid setting key or value: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SETTING);
} catch (Exception e) {
log.error("Unexpected error while updating setting: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(GENERIC_SERVER_ERROR);
}
}
private Object getSectionData(String sectionName) {
if (sectionName == null || sectionName.trim().isEmpty()) {
return null;
}
return switch (sectionName.toLowerCase()) {
case "security" -> applicationProperties.getSecurity();
case "system" -> applicationProperties.getSystem();
case "ui" -> applicationProperties.getUi();
case "endpoints" -> applicationProperties.getEndpoints();
case "metrics" -> applicationProperties.getMetrics();
case "mail" -> applicationProperties.getMail();
case "premium" -> applicationProperties.getPremium();
case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor();
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
case "legal" -> applicationProperties.getLegal();
default -> null;
};
}
private boolean isValidSectionName(String sectionName) {
return getSectionData(sectionName) != null;
}
private static final java.util.Set<String> VALID_SECTION_NAMES =
java.util.Set.of(
"security",
"system",
"ui",
"endpoints",
"metrics",
"mail",
"premium",
"processExecutor",
"processexecutor",
"autoPipeline",
"autopipeline",
"legal");
// Pattern to validate safe property paths - only alphanumeric, dots, and underscores
private static final Pattern SAFE_KEY_PATTERN = Pattern.compile("^[a-zA-Z0-9._]+$");
private static final int MAX_NESTING_DEPTH = 10;
// Security: Generic error messages to prevent information disclosure
private static final String GENERIC_INVALID_SETTING = "Invalid setting key or value.";
private static final String GENERIC_INVALID_SECTION = "Invalid section data provided.";
private static final String GENERIC_SERVER_ERROR = "Internal server error occurred.";
private static final String GENERIC_FILE_ERROR =
"Failed to save settings to configuration file.";
private boolean isValidSettingKey(String key) {
if (key == null || key.trim().isEmpty()) {
return false;
}
// Check against pattern to prevent injection attacks
if (!SAFE_KEY_PATTERN.matcher(key).matches()) {
return false;
}
// Prevent excessive nesting depth
String[] parts = key.split("\\.");
if (parts.length > MAX_NESTING_DEPTH) {
return false;
}
// Ensure first part is a valid section name
if (parts.length > 0 && !VALID_SECTION_NAMES.contains(parts[0].toLowerCase())) {
return false;
}
return true;
}
private Object getSettingByKey(String key) {
if (key == null || key.trim().isEmpty()) {
return null;
}
String[] parts = key.split("\\.", 2);
if (parts.length < 2) {
return null;
}
String sectionName = parts[0];
String propertyPath = parts[1];
Object section = getSectionData(sectionName);
if (section == null) {
return null;
}
try {
return getNestedProperty(section, propertyPath, 0);
} catch (NoSuchFieldException | IllegalAccessException e) {
log.warn("Failed to get nested property {}: {}", key, e.getMessage());
return null;
}
}
private Object getNestedProperty(Object obj, String propertyPath, int depth)
throws NoSuchFieldException, IllegalAccessException {
if (obj == null) {
return null;
}
// Prevent excessive recursion depth
if (depth > MAX_NESTING_DEPTH) {
throw new IllegalAccessException("Maximum nesting depth exceeded");
}
try {
// Use Jackson ObjectMapper for safer property access
@SuppressWarnings("unchecked")
Map<String, Object> objectMap = objectMapper.convertValue(obj, Map.class);
String[] parts = propertyPath.split("\\.", 2);
String currentProperty = parts[0];
if (!objectMap.containsKey(currentProperty)) {
throw new NoSuchFieldException("Property not found: " + currentProperty);
}
Object value = objectMap.get(currentProperty);
if (parts.length == 1) {
return value;
} else {
return getNestedProperty(value, parts[1], depth + 1);
}
} catch (IllegalArgumentException e) {
// If Jackson fails, the property doesn't exist or isn't accessible
throw new NoSuchFieldException("Property not accessible: " + propertyPath);
}
}
/**
* Recursively mask sensitive fields in settings map. Sensitive fields are replaced with a
* status indicator showing if they're configured.
*/
@SuppressWarnings("unchecked")
private Map<String, Object> maskSensitiveFields(Map<String, Object> settings) {
return maskSensitiveFieldsWithPath(settings, "");
}
@SuppressWarnings("unchecked")
private Map<String, Object> maskSensitiveFieldsWithPath(
Map<String, Object> settings, String path) {
Map<String, Object> masked = new HashMap<>();
for (Map.Entry<String, Object> entry : settings.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
String currentPath = path.isEmpty() ? key : path + "." + key;
if (value instanceof Map) {
// Recursively mask nested objects
masked.put(
key, maskSensitiveFieldsWithPath((Map<String, Object>) value, currentPath));
} else if (isSensitiveFieldWithPath(key, currentPath)) {
// Mask sensitive fields with status indicator
masked.put(key, createMaskedValue(value));
} else {
// Keep non-sensitive fields as-is
masked.put(key, value);
}
}
return masked;
}
/** Check if a field name indicates sensitive data with full path context */
private boolean isSensitiveFieldWithPath(String fieldName, String fullPath) {
String lowerField = fieldName.toLowerCase();
String lowerPath = fullPath.toLowerCase();
// Don't mask premium.key specifically
if ("key".equals(lowerField) && "premium.key".equals(lowerPath)) {
return false;
}
// Direct match with sensitive field names
if (SENSITIVE_FIELD_NAMES.contains(lowerField)) {
return true;
}
// Check for fields containing 'password' or 'secret'
return lowerField.contains("password") || lowerField.contains("secret");
}
/** Create a masked representation for sensitive fields */
private Object createMaskedValue(Object originalValue) {
if (originalValue == null
|| (originalValue instanceof String && ((String) originalValue).trim().isEmpty())) {
return originalValue; // Keep empty/null values as-is
} else {
return "********";
}
}
/** Merge pending changes into the settings map using dot notation keys */
@SuppressWarnings("unchecked")
private Map<String, Object> mergePendingChanges(
Map<String, Object> settings, Map<String, Object> pendingChanges) {
// Create a deep copy of the settings to avoid modifying the original
Map<String, Object> mergedSettings = new HashMap<>(settings);
for (Map.Entry<String, Object> pendingEntry : pendingChanges.entrySet()) {
String dotNotationKey = pendingEntry.getKey();
Object pendingValue = pendingEntry.getValue();
// Split the dot notation key into parts
String[] keyParts = dotNotationKey.split("\\.");
// Navigate to the parent object and set the value
Map<String, Object> currentMap = mergedSettings;
// Navigate through all parts except the last one
for (int i = 0; i < keyParts.length - 1; i++) {
String keyPart = keyParts[i];
// Get or create the nested map
Object nested = currentMap.get(keyPart);
if (!(nested instanceof Map)) {
// Create a new nested map if it doesn't exist or isn't a map
nested = new HashMap<String, Object>();
currentMap.put(keyPart, nested);
}
currentMap = (Map<String, Object>) nested;
}
// Set the final value
String finalKey = keyParts[keyParts.length - 1];
currentMap.put(finalKey, pendingValue);
}
return mergedSettings;
}
}
@@ -1,22 +0,0 @@
package stirling.software.proprietary.security.model.api.admin;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Response containing a setting key and its current value")
public class SettingValueResponse {
@Schema(
description = "The setting key in dot notation (e.g., 'system.enableAnalytics')",
example = "system.enableAnalytics")
private String key;
@Schema(description = "The current value of the setting", example = "true")
private Object value;
}
@@ -1,16 +0,0 @@
package stirling.software.proprietary.security.model.api.admin;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "Request to update a single setting value")
public class UpdateSettingValueRequest {
@NotNull
@Schema(description = "The new value for the setting", example = "false")
private Object value;
}
@@ -1,23 +0,0 @@
package stirling.software.proprietary.security.model.api.admin;
import java.util.Map;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "Request to update multiple application settings using delta updates")
public class UpdateSettingsRequest {
@NotNull
@NotEmpty
@Schema(
description =
"Map of setting keys to their new values using dot notation. Only changed values need to be included for delta updates.",
example = "{\"system.enableAnalytics\": false, \"ui.appName\": \"My PDF Tool\"}")
private Map<String, Object> settings;
}
@@ -1,20 +1,17 @@
package stirling.software.proprietary.security;
import static org.mockito.Mockito.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.common.model.ApplicationProperties;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CustomLogoutSuccessHandlerTest {
@@ -1,10 +1,6 @@
package stirling.software.proprietary.security.configuration;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
import javax.sql.DataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -12,9 +8,10 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DatabaseConfigTest {
@@ -2,10 +2,8 @@ package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -19,6 +17,9 @@ import jakarta.mail.internet.MimeMessage;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.api.Email;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class EmailServiceTest {
@@ -1,26 +1,25 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.repository.TeamRepository;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TeamServiceTest {
@Mock private TeamRepository teamRepository;
@Mock
private TeamRepository teamRepository;
@InjectMocks private TeamService teamService;
@InjectMocks
private TeamService teamService;
@Test
void getDefaultTeam() {
@@ -42,7 +41,8 @@ class TeamServiceTest {
defaultTeam.setId(1L);
defaultTeam.setName(teamName);
when(teamRepository.findByName(teamName)).thenReturn(Optional.empty());
when(teamRepository.findByName(teamName))
.thenReturn(Optional.empty());
when(teamRepository.save(any(Team.class))).thenReturn(defaultTeam);
Team result = teamService.getOrCreateDefaultTeam();
@@ -56,7 +56,7 @@ class TeamServiceTest {
team.setName("Eldians");
when(teamRepository.findByName(TeamService.INTERNAL_TEAM_NAME))
.thenReturn(Optional.of(team));
.thenReturn(Optional.of(team));
Team result = teamService.getOrCreateInternalTeam();
@@ -70,10 +70,11 @@ class TeamServiceTest {
internalTeam.setId(2L);
internalTeam.setName(teamName);
when(teamRepository.findByName(teamName)).thenReturn(Optional.empty());
when(teamRepository.findByName(teamName))
.thenReturn(Optional.empty());
when(teamRepository.save(any(Team.class))).thenReturn(internalTeam);
when(teamRepository.findByName(TeamService.INTERNAL_TEAM_NAME))
.thenReturn(Optional.empty());
.thenReturn(Optional.empty());
Team result = teamService.getOrCreateInternalTeam();
@@ -1,13 +1,8 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
import java.sql.SQLException;
import java.util.Locale;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -16,9 +11,10 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.MessageSource;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
@@ -26,27 +22,42 @@ import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock private UserRepository userRepository;
@Mock
private UserRepository userRepository;
@Mock private TeamRepository teamRepository;
@Mock
private TeamRepository teamRepository;
@Mock private AuthorityRepository authorityRepository;
@Mock
private AuthorityRepository authorityRepository;
@Mock private PasswordEncoder passwordEncoder;
@Mock
private PasswordEncoder passwordEncoder;
@Mock private MessageSource messageSource;
@Mock
private MessageSource messageSource;
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
@Mock
private SessionPersistentRegistry sessionPersistentRegistry;
@Mock private DatabaseServiceInterface databaseService;
@Mock
private DatabaseServiceInterface databaseService;
@Mock private ApplicationProperties.Security.OAUTH2 oauth2Properties;
@Mock
private ApplicationProperties.Security.OAUTH2 oauth2Properties;
@InjectMocks private UserService userService;
@InjectMocks
private UserService userService;
private Team mockTeam;
private User mockUser;
@@ -135,10 +146,10 @@ class UserServiceTest {
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(invalidUsername, authType));
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(invalidUsername, authType)
);
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
@@ -210,10 +221,10 @@ class UserServiceTest {
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(reservedUsername, authType));
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(reservedUsername, authType)
);
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
@@ -226,10 +237,10 @@ class UserServiceTest {
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(anonymousUsername, authType));
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(anonymousUsername, authType)
);
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
@@ -302,4 +313,5 @@ class UserServiceTest {
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
}
+20 -25
View File
@@ -2,11 +2,11 @@ plugins {
id "java"
id "jacoco"
id "io.spring.dependency-management" version "1.1.7"
id "org.springframework.boot" version "3.5.4"
id "org.springframework.boot" version "3.5.3"
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
id "io.swagger.swaggerhub" version "1.3.2"
id "edu.sc.seis.launch4j" version "3.0.7"
id "com.diffplug.spotless" version "7.2.1"
id "edu.sc.seis.launch4j" version "3.0.6"
id "com.diffplug.spotless" version "7.1.0"
id "com.github.jk1.dependency-license-report" version "2.9"
//id "nebula.lint" version "19.0.3"
id "org.panteleyev.jpackageplugin" version "1.7.3"
@@ -21,15 +21,15 @@ import java.nio.file.Files
import java.time.Year
ext {
springBootVersion = "3.5.4"
springBootVersion = "3.5.3"
pdfboxVersion = "3.0.5"
imageioVersion = "3.12.0"
lombokVersion = "1.18.38"
bouncycastleVersion = "1.81"
springSecuritySamlVersion = "6.5.2"
springSecuritySamlVersion = "6.5.1"
openSamlVersion = "4.3.2"
commonmarkVersion = "0.25.0"
googleJavaFormatVersion = "1.28.0"
googleJavaFormatVersion = "1.27.0"
tempJrePath = null
}
@@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '1.1.2'
version = '1.0.2'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
@@ -128,13 +128,6 @@ subprojects {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:$springBootVersion"
}
dependencies {
// Security vulnerability fixes - remove when Spring Security updates these
dependency 'com.nimbusds:nimbus-jose-jwt:10.0.2' // CVE-2025-53864 - from spring-boot-starter-oauth2-client
dependency 'com.google.guava:guava:33.4.8-jre' // CVE-2023-2976, CVE-2020-8908 - from OpenSAML dependencies
dependency 'commons-io:commons-io:2.14.0' // CVE-2024-47554 - from various dependencies
dependency 'org.apache.commons:commons-lang3:3.18.0' // CVE-2025-48924 - from transitive dependencies
}
}
dependencies {
@@ -165,9 +158,9 @@ subprojects {
useJUnitPlatform()
}
tasks.named("processResources") {
dependsOn(rootProject.tasks.writeVersion)
}
tasks.named("processResources") {
dependsOn(rootProject.tasks.writeVersion)
}
if (name == 'stirling-pdf') {
apply plugin: 'org.springdoc.openapi-gradle-plugin'
@@ -535,14 +528,16 @@ launch4j {
}
spotless {
yaml {
target '*.yml', '*.yaml'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
format 'gradle', {
target 'build.gradle', 'settings.gradle', 'gradle/*.gradle', 'gradle/**/*.gradle'
java {
target sourceSets.main.allJava
target project(':common').sourceSets.main.allJava
target project(':proprietary').sourceSets.main.allJava
target project(':stirling-pdf').sourceSets.main.allJava
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
toggleOffOn()
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()

Some files were not shown because too many files have changed in this diff Show More